Sfoglia il codice sorgente

feat(release): version query skills and complete production handoff

pengzhikang 5 ore fa
parent
commit
f4bdaa70a1
100 ha cambiato i file con 9566 aggiunte e 0 eliminazioni
  1. 2 0
      .gitattributes
  2. 23 0
      .gitignore
  3. 841 0
      deploy/board-files-20261106.json
  4. 21 0
      deploy/complete-release-plan.json
  5. 17 0
      deploy/complete-release-validation.json
  6. 55 0
      deploy/opportunity-ui-files-20261103.json
  7. 32 0
      deploy/report-evidence-files-20261102.json
  8. 76 0
      deploy/workshop-files-20261101.json
  9. 35 0
      docs/README.md
  10. 49 0
      docs/aibot-adapter-v3-review-20261102.md
  11. 107 0
      docs/board-deploy-20261106/发给运维001-部署已推送看板.md
  12. 75 0
      docs/board-deploy-20261106/完整发布执行单.md
  13. 35 0
      docs/first-live-S1-20261102.md
  14. 48 0
      docs/first-live-sources-20261102.md
  15. 110 0
      docs/innovation-dashboard-plan-20261103/01-需求理解与五端方案.md
  16. 93 0
      docs/innovation-dashboard-plan-20261103/02-模拟数据与产品故事线.md
  17. 57 0
      docs/innovation-dashboard-plan-20261103/03-PC效果图出图说明.md
  18. 113 0
      docs/innovation-dashboard-plan-20261103/04-五个系统介绍长页内容.md
  19. 75 0
      docs/innovation-dashboard-plan-20261103/05-技术规范与验收范围.md
  20. 26 0
      docs/innovation-dashboard-plan-20261103/06-转给王刚001的消息.txt
  21. 13 0
      docs/voc-agent-query-20261106/发给王刚001.md
  22. 57 0
      docs/voc-agent-query-20261106/发给运维001.md
  23. 23 0
      docs/voc-agent-query-20261106/数据库与Agent规则.md
  24. 45 0
      docs/voc-database-delivery-20261106/发给运维001-数据库分包投递.md
  25. 69 0
      docs/voc-database-delivery-20261106/发给运维001-补装研究数据库.md
  26. 13 0
      docs/voc-database-delivery-20261106/给王刚001-数据库就绪后继续验收.md
  27. 27 0
      docs/voc-structure-20261105/发给王刚001.md
  28. 50 0
      docs/voc-structure-20261105/发给运维001.md
  29. 59 0
      docs/voc-structure-20261105/数据库与Agent规则.md
  30. 33 0
      docs/wecom-aibot-identity-handoff.md
  31. 51 0
      docs/wecom-current-employee-registration.md
  32. 41 0
      docs/wecom-tool-result-hotfix-20261102.md
  33. 49 0
      docs/workshop-dedup-20261102.md
  34. 53 0
      docs/workshop-dedup-acceptance-20261102.md
  35. 51 0
      docs/workshop-identity.md
  36. 113 0
      docs/workshop-integration.md
  37. 94 0
      docs/workshop-real-message-progress-20261102.md
  38. 69 0
      docs/workshop-today-full-chain-20261102.md
  39. 8 0
      integrations/wecom-identity/__init__.py
  40. 180 0
      integrations/wecom-identity/identity_bridge.py
  41. 5 0
      integrations/wecom-identity/registration.example.json
  42. 128 0
      integrations/wecom-identity/workshop_registration.py
  43. 2 0
      package.json
  44. 39 0
      scripts/assemble-board-handoff.py
  45. 53 0
      scripts/audit-release-workspace.py
  46. 14 0
      scripts/build-dedup-manifest.mjs
  47. 8 0
      scripts/build-identity-manifest.mjs
  48. 13 0
      scripts/build-workshop-skill.mjs
  49. 64 0
      scripts/collect-S4-public-images.py
  50. 86 0
      scripts/collect-S4-public-pages.py
  51. 83 0
      scripts/collect-first-live-sources.py
  52. 50 0
      scripts/diagnose-category-import-20261102.mjs
  53. 66 0
      scripts/finalize-board-handoff.py
  54. 88 0
      scripts/inspect-S4-browser-display.mjs
  55. 20 0
      scripts/inspect-S4-public-records.mjs
  56. 15 0
      scripts/inspect-workshop-data.mjs
  57. 156 0
      scripts/install-query-skills.py
  58. 144 0
      scripts/install-voc-database.py
  59. 142 0
      scripts/install-wecom-tool-result-hotfix.py
  60. 83 0
      scripts/install-workshop-skill.mjs
  61. 202 0
      scripts/package-S4-public-research.py
  62. 109 0
      scripts/package-S5-classroom-next.py
  63. 107 0
      scripts/package-S5-completion.py
  64. 114 0
      scripts/package-complete-release.py
  65. 55 0
      scripts/package-current-employee-prep.py
  66. 206 0
      scripts/package-current-product-observations.py
  67. 61 0
      scripts/package-dedup-acceptance.py
  68. 68 0
      scripts/package-first-live-sources.py
  69. 98 0
      scripts/package-live-S1-v2.py
  70. 107 0
      scripts/package-live-S3-v2.py
  71. 184 0
      scripts/package-new-workshop-agent.py
  72. 134 0
      scripts/package-query-skills.py
  73. 76 0
      scripts/package-report-evidence-ui.py
  74. 176 0
      scripts/package-voc-database.py
  75. 138 0
      scripts/package-voc-library-r2.py
  76. 174 0
      scripts/package-voc-library.py
  77. 74 0
      scripts/package-workshop-identity.mjs
  78. 84 0
      scripts/package-workshop.mjs
  79. 242 0
      scripts/prepare-S4-classroom-next.py
  80. 288 0
      scripts/prepare-S5-classroom-next.py
  81. 143 0
      scripts/prepare-S5-completion.py
  82. 76 0
      scripts/prepare-board-deployment.py
  83. 233 0
      scripts/prepare-consumer-quote-inputs.py
  84. 68 0
      scripts/prepare-first-live-S1.py
  85. 231 0
      scripts/prepare-live-S2.py
  86. 100 0
      scripts/publish-opportunity-ui.mjs
  87. 99 0
      scripts/publish-report-evidence.mjs
  88. 103 0
      scripts/publish-workshop-files.mjs
  89. 110 0
      scripts/reassemble-voc-database.py
  90. 169 0
      scripts/simulate-S4-classroom-next.mjs
  91. 134 0
      scripts/simulate-S5-classroom-next.mjs
  92. 76 0
      scripts/simulate-S5-completion.mjs
  93. 125 0
      scripts/split-identity-delivery.py
  94. 97 0
      scripts/split-voc-database-delivery.py
  95. 35 0
      scripts/test-board-deployment.mjs
  96. 195 0
      scripts/update-S4-acceptance.py
  97. 44 0
      scripts/update-S4-public-progress.py
  98. 295 0
      scripts/update-S5-acceptance.py
  99. 201 0
      scripts/update-S5-completion-acceptance.py
  100. 88 0
      scripts/verify-S4-classroom-contract.mjs

+ 2 - 0
.gitattributes

@@ -0,0 +1,2 @@
+# Skill manifests hash the delivered bytes; Git must preserve those bytes exactly.
+skills/** -text whitespace=cr-at-eol

+ 23 - 0
.gitignore

@@ -46,6 +46,29 @@ __pycache__/
 *.db-wal
 *.swp
 docs/v15-before.json
+
+# 本地交付包、资料副本和预览数据;生产按独立校验包投递
+artifacts/
+data/
+
+# 已退出生产路由的本地演示草稿及专属大体积模拟素材
+src/assets/showcase/
+src/core/sample.js
+src/core/showcase.js
+src/layout/sample-shell.js
+src/pages/sample-base.js
+src/pages/sample-detail.js
+src/pages/showcase-content.js
+src/pages/view-class.js
+src/pages/view-cockpit.js
+src/pages/view-coop.js
+src/pages/view-intel.js
+src/pages/view-review.js
+src/pages/view-showcase.js
+src/services/sample-data.js
+src/services/showcase-data.js
+test/sample-flow.test.js
+test/showcase-sample.test.js
 .v15-base/
 docs/v15-after.json
 docs/v15-before.json

+ 841 - 0
deploy/board-files-20261106.json

@@ -0,0 +1,841 @@
+{
+  "version": "ksf.board.publish.v1",
+  "release": "ksf.board.20261106.r1",
+  "commit": "0ba3c0a07afb70aed54713739d3c128dacc1aaf0",
+  "origin": "https://kangshifu.fmode.cn",
+  "homepageSha256": "dfeb6a6d0b63e8e1190f9308bf6619a5ca523a5a2f91af5f7ca5ad5fc9642db3",
+  "files": [
+    {
+      "path": "src/app.config.js",
+      "sha256": "42a131877bded0ffa0bde82a099154b6c5952ad712c3c2d7b62cb19e187cd49a",
+      "priorNormalizedSha256": [
+        "e8798ef2a1f77d49f93c360f0b6b804bb94e22534ef60362a537918d592e6f37"
+      ],
+      "allowMissing": false,
+      "observedState": "static-file",
+      "observedSha256": "e8798ef2a1f77d49f93c360f0b6b804bb94e22534ef60362a537918d592e6f37",
+      "changed": true,
+      "matchesKnownGitVersion": true
+    },
+    {
+      "path": "src/app.js",
+      "sha256": "60c93012411fdbfc8f1846deeed5367cc07912a4199423d35487f6953903a7d0",
+      "priorNormalizedSha256": [
+        "578670a3df6ce7b1690812754243af4b7ef5ed5a467e1c0bf3f3da65eff6d1cf"
+      ],
+      "allowMissing": false,
+      "observedState": "static-file",
+      "observedSha256": "578670a3df6ce7b1690812754243af4b7ef5ed5a467e1c0bf3f3da65eff6d1cf",
+      "changed": false,
+      "matchesKnownGitVersion": true
+    },
+    {
+      "path": "src/assets/inter-latin.woff2",
+      "sha256": "3100e775e8616cd2611beecfa23a4263d7037586789b43f035236a2e6fbd4c62",
+      "priorNormalizedSha256": [
+        "3100e775e8616cd2611beecfa23a4263d7037586789b43f035236a2e6fbd4c62"
+      ],
+      "allowMissing": false,
+      "observedState": "static-file",
+      "observedSha256": "3100e775e8616cd2611beecfa23a4263d7037586789b43f035236a2e6fbd4c62",
+      "changed": false,
+      "matchesKnownGitVersion": true
+    },
+    {
+      "path": "src/components/base.js",
+      "sha256": "c231ccae925c67b9e59a1f922debc5d6823a8885b15a14f00489dc067b76dee9",
+      "priorNormalizedSha256": [
+        "a4bcd43fb5506a9245bdc6b1d371aa742e2b71627cfc1119ecb97c5aceb7349d"
+      ],
+      "allowMissing": false,
+      "observedState": "static-file",
+      "observedSha256": "a4bcd43fb5506a9245bdc6b1d371aa742e2b71627cfc1119ecb97c5aceb7349d",
+      "changed": false,
+      "matchesKnownGitVersion": true
+    },
+    {
+      "path": "src/components/cjk-text.js",
+      "sha256": "761dc2c9856b9729d9a15b2fae1b96b05e14e835ce7adaaf147fedb87bc38edb",
+      "priorNormalizedSha256": [
+        "164b9e4ebfc3ef010a48eb4b7449088158a1b260235e7c9388a15c7758ff03f1"
+      ],
+      "allowMissing": false,
+      "observedState": "static-file",
+      "observedSha256": "164b9e4ebfc3ef010a48eb4b7449088158a1b260235e7c9388a15c7758ff03f1",
+      "changed": false,
+      "matchesKnownGitVersion": true
+    },
+    {
+      "path": "src/components/cjk.js",
+      "sha256": "3516120d9835270cc11c330d4e08d7f6fa9eaff05ae07d240d35d964fa4ffaa0",
+      "priorNormalizedSha256": [
+        "a0686e11325493394016823e813e9c48acd2c9e6735714c6f063dcf1ba05cd0f"
+      ],
+      "allowMissing": false,
+      "observedState": "static-file",
+      "observedSha256": "a0686e11325493394016823e813e9c48acd2c9e6735714c6f063dcf1ba05cd0f",
+      "changed": false,
+      "matchesKnownGitVersion": true
+    },
+    {
+      "path": "src/components/icons.js",
+      "sha256": "938ebc684cf044eb78921ab696a85cfaec32d7ec67166f578c889d918e9245b4",
+      "priorNormalizedSha256": [
+        "2bee63de20b56a7bf9eff6a6ab70dacfe9865cce014405ae7bbfa326e8d22b88"
+      ],
+      "allowMissing": false,
+      "observedState": "static-file",
+      "observedSha256": "2bee63de20b56a7bf9eff6a6ab70dacfe9865cce014405ae7bbfa326e8d22b88",
+      "changed": true,
+      "matchesKnownGitVersion": true
+    },
+    {
+      "path": "src/components/index.js",
+      "sha256": "1d422169fa605e2fee48529b44cf5591351eca1349ee430ff0458403c1dc4bb1",
+      "priorNormalizedSha256": [
+        "8af3381a0fa90d08bfc9883dbb6bc6246cfee1e3b68d2fc2377dedbac6bb9ae0"
+      ],
+      "allowMissing": false,
+      "observedState": "static-file",
+      "observedSha256": "8af3381a0fa90d08bfc9883dbb6bc6246cfee1e3b68d2fc2377dedbac6bb9ae0",
+      "changed": false,
+      "matchesKnownGitVersion": true
+    },
+    {
+      "path": "src/components/kf-badge.js",
+      "sha256": "7e7214cbed12493924b577a929bb225254b6aff9af1843c97acba59809469b90",
+      "priorNormalizedSha256": [
+        "e3ceae48f866067de873233d094e50bfc7707b7b6bb48cba63006293f1a910ae"
+      ],
+      "allowMissing": false,
+      "observedState": "static-file",
+      "observedSha256": "e3ceae48f866067de873233d094e50bfc7707b7b6bb48cba63006293f1a910ae",
+      "changed": false,
+      "matchesKnownGitVersion": true
+    },
+    {
+      "path": "src/components/kf-drawer.js",
+      "sha256": "d2d3962e0385cd273294e172d8543573650a5b97e839f792430df6d2ed7efd93",
+      "priorNormalizedSha256": [
+        "dcae994d33373edca158bf3e07bbfd461a291a949b96665009738e8a58d720b1"
+      ],
+      "allowMissing": false,
+      "observedState": "static-file",
+      "observedSha256": "dcae994d33373edca158bf3e07bbfd461a291a949b96665009738e8a58d720b1",
+      "changed": true,
+      "matchesKnownGitVersion": true
+    },
+    {
+      "path": "src/components/kf-empty.js",
+      "sha256": "056c981dbeab6f716fe73b36deb533a8e9a5245103b167084ecca6df825dc94f",
+      "priorNormalizedSha256": [
+        "772b238028885ad360b34408a57a554b1434a772c1c37913519f79615ef37b13"
+      ],
+      "allowMissing": false,
+      "observedState": "static-file",
+      "observedSha256": "772b238028885ad360b34408a57a554b1434a772c1c37913519f79615ef37b13",
+      "changed": true,
+      "matchesKnownGitVersion": true
+    },
+    {
+      "path": "src/components/kf-evidence-chain.js",
+      "sha256": "500d79e207d1624bfddb97f704bd2822aa6578def21780a6b6fb0afdca7d1e8f",
+      "priorNormalizedSha256": [
+        "505171ba688b6ecb12f4e24e325d0df32bb150a687debd584dbb7253ae25699f"
+      ],
+      "allowMissing": false,
+      "observedState": "static-file",
+      "observedSha256": "505171ba688b6ecb12f4e24e325d0df32bb150a687debd584dbb7253ae25699f",
+      "changed": true,
+      "matchesKnownGitVersion": true
+    },
+    {
+      "path": "src/components/kf-glass-card.js",
+      "sha256": "9d2ebe1d9efadd1f3cb30315e37367f60c44b45212b2808522314e6c2551adcf",
+      "priorNormalizedSha256": [
+        "516b5529187abded2df5ffe291453635e0dcff299b8dbc245407fa9a69fb6453"
+      ],
+      "allowMissing": false,
+      "observedState": "static-file",
+      "observedSha256": "516b5529187abded2df5ffe291453635e0dcff299b8dbc245407fa9a69fb6453",
+      "changed": true,
+      "matchesKnownGitVersion": true
+    },
+    {
+      "path": "src/components/kf-opportunity-card.js",
+      "sha256": "db98e1d52ac49e24d94ffe09c01ddd2015909530c715bf38ba19e850b8c04d23",
+      "priorNormalizedSha256": [
+        "feacdb73b680c3292e6742ed17bf2a199d52ae8d161376df27d0dd86ed6a1975"
+      ],
+      "allowMissing": false,
+      "observedState": "static-file",
+      "observedSha256": "faa9e0850f7a4cee47deb2ead3ec546d18a2ee3cbedfd21861fc4e58dcea1cfe",
+      "changed": true,
+      "matchesKnownGitVersion": false
+    },
+    {
+      "path": "src/components/kf-pipeline.js",
+      "sha256": "b870d83516b59c9614f5b392381bfa128d59bbdb041e48c3b6022a3129e3f750",
+      "priorNormalizedSha256": [
+        "257c02bfec8aee4d538f1e56f017ae133115bd76af6dc95c06de890ddf86ada6"
+      ],
+      "allowMissing": false,
+      "observedState": "static-file",
+      "observedSha256": "257c02bfec8aee4d538f1e56f017ae133115bd76af6dc95c06de890ddf86ada6",
+      "changed": false,
+      "matchesKnownGitVersion": true
+    },
+    {
+      "path": "src/components/kf-score-panel.js",
+      "sha256": "d3c80fc8c4c8b210c69b49f764a1738d85a959a32f70e7f96301114b07e055a9",
+      "priorNormalizedSha256": [
+        "3a42499bf14a06d78f5299b3afb4821e0dd5e9581095b62bfbaad304545bf753"
+      ],
+      "allowMissing": false,
+      "observedState": "static-file",
+      "observedSha256": "3a42499bf14a06d78f5299b3afb4821e0dd5e9581095b62bfbaad304545bf753",
+      "changed": false,
+      "matchesKnownGitVersion": true
+    },
+    {
+      "path": "src/components/kf-source-detail.js",
+      "sha256": "9c61966edaf5f7df6d593a5d0724d0c24c2aab8e487b8d128692f77d218a4d00",
+      "priorNormalizedSha256": [],
+      "allowMissing": true,
+      "observedState": "spa-fallback",
+      "observedSha256": "dfeb6a6d0b63e8e1190f9308bf6619a5ca523a5a2f91af5f7ca5ad5fc9642db3",
+      "changed": true,
+      "matchesKnownGitVersion": true
+    },
+    {
+      "path": "src/components/kf-stat.js",
+      "sha256": "89d26ca79a2b734df9c5a59619fd82f353788a727902ade187c282429b0728a9",
+      "priorNormalizedSha256": [
+        "b6577c0136b422771a448d518db9837f08a8b60d5ebfeddbfd7f297eb5b6e071"
+      ],
+      "allowMissing": false,
+      "observedState": "static-file",
+      "observedSha256": "b6577c0136b422771a448d518db9837f08a8b60d5ebfeddbfd7f297eb5b6e071",
+      "changed": false,
+      "matchesKnownGitVersion": true
+    },
+    {
+      "path": "src/components/kf-table.js",
+      "sha256": "cead5f9953a0fbe23eab7c5b6e2bf6a0af791df8da993ee5fd288196b633a030",
+      "priorNormalizedSha256": [
+        "c4d1eaa1aa5b11ea62c42d3ad2c85dc0c1ca5c2109c819fc089e0295801df7b4"
+      ],
+      "allowMissing": false,
+      "observedState": "static-file",
+      "observedSha256": "c4d1eaa1aa5b11ea62c42d3ad2c85dc0c1ca5c2109c819fc089e0295801df7b4",
+      "changed": false,
+      "matchesKnownGitVersion": true
+    },
+    {
+      "path": "src/components/kf-toast.js",
+      "sha256": "0c1cdd6b81ddcb51706637b97d85f329bbfb75aa6442d170344bca3736a627a2",
+      "priorNormalizedSha256": [
+        "388e021bfafb2491378f6872142f3f009292b74f6743121d590f2882bed0327d"
+      ],
+      "allowMissing": false,
+      "observedState": "static-file",
+      "observedSha256": "388e021bfafb2491378f6872142f3f009292b74f6743121d590f2882bed0327d",
+      "changed": false,
+      "matchesKnownGitVersion": true
+    },
+    {
+      "path": "src/components/kf-viz.js",
+      "sha256": "8c563ffc4eafe9c198d707bee5595e19edb32ffe595745c9e01d904b57b299b2",
+      "priorNormalizedSha256": [
+        "c5d686b8e14ea8051f2b1999846671cb018e32a5afb0d464d39d809df86d2e73"
+      ],
+      "allowMissing": false,
+      "observedState": "static-file",
+      "observedSha256": "c5d686b8e14ea8051f2b1999846671cb018e32a5afb0d464d39d809df86d2e73",
+      "changed": true,
+      "matchesKnownGitVersion": true
+    },
+    {
+      "path": "src/core/evidence/chain.js",
+      "sha256": "3798974a75fa078cc2c6cef30651bb250917b35fa79625f90d3e89df401a9fb3",
+      "priorNormalizedSha256": [
+        "91060709ebb32ff0969c24fb797d5f6ad7be4824d311078517effb3acbe2ee29"
+      ],
+      "allowMissing": false,
+      "observedState": "static-file",
+      "observedSha256": "3798974a75fa078cc2c6cef30651bb250917b35fa79625f90d3e89df401a9fb3",
+      "changed": false,
+      "matchesKnownGitVersion": true
+    },
+    {
+      "path": "src/core/evidence/index.js",
+      "sha256": "553ad9073b00ea4edc0d4af0e22bbb513aa85cb2437241e7f6949decc10fc4a8",
+      "priorNormalizedSha256": [
+        "ece8c834182c01d4c1cb83d8b10adf118d961fd67a16696db5b9ce417d4b354b"
+      ],
+      "allowMissing": false,
+      "observedState": "static-file",
+      "observedSha256": "ece8c834182c01d4c1cb83d8b10adf118d961fd67a16696db5b9ce417d4b354b",
+      "changed": false,
+      "matchesKnownGitVersion": true
+    },
+    {
+      "path": "src/core/index.js",
+      "sha256": "86c14da3cec2b34e006bb607cb3540912b2f5e1511c51aa9ad90c45adcc80cb9",
+      "priorNormalizedSha256": [
+        "01d145691d1d87d5b7e2464f7c8585319619a23843ac665c29ec3ac505174da7"
+      ],
+      "allowMissing": false,
+      "observedState": "static-file",
+      "observedSha256": "01d145691d1d87d5b7e2464f7c8585319619a23843ac665c29ec3ac505174da7",
+      "changed": false,
+      "matchesKnownGitVersion": true
+    },
+    {
+      "path": "src/core/kfs/build.js",
+      "sha256": "71321e57f2b48f12c87fa6953fa6e7431aa8476c64f17e78e541ffc86ff8938c",
+      "priorNormalizedSha256": [
+        "c594b07804e03fdbde425dc0280c8c9164d9ac97f23d96fcb9406862c0c16c91"
+      ],
+      "allowMissing": false,
+      "observedState": "static-file",
+      "observedSha256": "05dd6acd7f5f0d89dd4b29a1eccd217685b7aacc56cbb4357e948674491a081a",
+      "changed": true,
+      "matchesKnownGitVersion": false
+    },
+    {
+      "path": "src/core/kfs/employee.js",
+      "sha256": "9c27f961bca7886378ab8bf7a446b8e0a7d54f7bd52e00d6ad33856612085e77",
+      "priorNormalizedSha256": [
+        "113fa23092992186eec9104a6c0af4bdb7b323579e43a11421ea4d6193d38b5d"
+      ],
+      "allowMissing": false,
+      "observedState": "static-file",
+      "observedSha256": "113fa23092992186eec9104a6c0af4bdb7b323579e43a11421ea4d6193d38b5d",
+      "changed": false,
+      "matchesKnownGitVersion": true
+    },
+    {
+      "path": "src/core/kfs/index.js",
+      "sha256": "0911546bc76d3770c10d2ec879ef38299fe9393484d44624d26c61899429deca",
+      "priorNormalizedSha256": [
+        "e25feeeb0ec6a7a1cbd49f6627fbac7c8b2c3a711a6f43ab9e582fb0fa71857e"
+      ],
+      "allowMissing": false,
+      "observedState": "static-file",
+      "observedSha256": "e25feeeb0ec6a7a1cbd49f6627fbac7c8b2c3a711a6f43ab9e582fb0fa71857e",
+      "changed": false,
+      "matchesKnownGitVersion": true
+    },
+    {
+      "path": "src/core/kfs/ledger.js",
+      "sha256": "7242962b039f7ee307566b8dd90d0e3666f82bb7480b643f24d676db84446134",
+      "priorNormalizedSha256": [
+        "0ab77bc4db0ad98d184285e7662857d03b90aaa6708f2b9025e16597dc2fecd7"
+      ],
+      "allowMissing": false,
+      "observedState": "static-file",
+      "observedSha256": "0ab77bc4db0ad98d184285e7662857d03b90aaa6708f2b9025e16597dc2fecd7",
+      "changed": false,
+      "matchesKnownGitVersion": true
+    },
+    {
+      "path": "src/core/kfs/stats.js",
+      "sha256": "6e967eb9c4e711b13f28a3826b6b56f21d85a41f08dd291e56dca9f4a41d2e9c",
+      "priorNormalizedSha256": [
+        "0ad541772cbacbf59302fcbfb8fb20cf04facb98ea3399bad765915e7a1f9112"
+      ],
+      "allowMissing": false,
+      "observedState": "static-file",
+      "observedSha256": "0ad541772cbacbf59302fcbfb8fb20cf04facb98ea3399bad765915e7a1f9112",
+      "changed": false,
+      "matchesKnownGitVersion": true
+    },
+    {
+      "path": "src/core/kfs/util.js",
+      "sha256": "7a5a682c0a93e1c78e1b24f3aeb5150265450b35baad332ccdb7345ba655c2ee",
+      "priorNormalizedSha256": [
+        "c0e77275f414bd2198dec161cf9c4d16dd27dfd99a587b4f7cfb5e66f61dec45"
+      ],
+      "allowMissing": false,
+      "observedState": "static-file",
+      "observedSha256": "c0e77275f414bd2198dec161cf9c4d16dd27dfd99a587b4f7cfb5e66f61dec45",
+      "changed": false,
+      "matchesKnownGitVersion": true
+    },
+    {
+      "path": "src/core/library.js",
+      "sha256": "9493aa5221e6be5c0f62bd925e8f2307c8ac03b25a7f4e6e99465d2b4ec521fb",
+      "priorNormalizedSha256": [],
+      "allowMissing": true,
+      "observedState": "spa-fallback",
+      "observedSha256": "dfeb6a6d0b63e8e1190f9308bf6619a5ca523a5a2f91af5f7ca5ad5fc9642db3",
+      "changed": true,
+      "matchesKnownGitVersion": true
+    },
+    {
+      "path": "src/core/library/reports.js",
+      "sha256": "e0ec7e6a2658760763b24f91c56bb18abfc826a7fb208fff723c4f7974be2af2",
+      "priorNormalizedSha256": [
+        "e02a0faa4706bc7ded7b35443822e7ce2da9496553b2a13924d33ddee3aa0724"
+      ],
+      "allowMissing": false,
+      "observedState": "static-file",
+      "observedSha256": "e02a0faa4706bc7ded7b35443822e7ce2da9496553b2a13924d33ddee3aa0724",
+      "changed": true,
+      "matchesKnownGitVersion": true
+    },
+    {
+      "path": "src/core/library/types.js",
+      "sha256": "a00bedc880b53ad25db83f36079cff62312506539f10ce4c650e106363b58500",
+      "priorNormalizedSha256": [
+        "e8bc02fb346528f66e46faac7815921c74f9da8c1ecf9371ff3396a44d2f0ef8"
+      ],
+      "allowMissing": false,
+      "observedState": "static-file",
+      "observedSha256": "e8bc02fb346528f66e46faac7815921c74f9da8c1ecf9371ff3396a44d2f0ef8",
+      "changed": false,
+      "matchesKnownGitVersion": true
+    },
+    {
+      "path": "src/core/member-groups.js",
+      "sha256": "e856485660ef2efc641bb854cd2680b9be0603a80e7f4de01013e170a28e66d0",
+      "priorNormalizedSha256": [],
+      "allowMissing": true,
+      "observedState": "spa-fallback",
+      "observedSha256": "dfeb6a6d0b63e8e1190f9308bf6619a5ca523a5a2f91af5f7ca5ad5fc9642db3",
+      "changed": true,
+      "matchesKnownGitVersion": true
+    },
+    {
+      "path": "src/core/opportunity/index.js",
+      "sha256": "3abaf3d9df0c2ba59e9736a0bb52acd8c1a4860059e763cf1233a84be40372df",
+      "priorNormalizedSha256": [
+        "e57706ac5133a9517fcc8249b5d9e245eef922a2d311a40f333c752fb2d2dc85"
+      ],
+      "allowMissing": false,
+      "observedState": "static-file",
+      "observedSha256": "e57706ac5133a9517fcc8249b5d9e245eef922a2d311a40f333c752fb2d2dc85",
+      "changed": false,
+      "matchesKnownGitVersion": true
+    },
+    {
+      "path": "src/core/opportunity/opportunity.js",
+      "sha256": "14c198eefc24883d75dd61a05cc2245c44da2f8cb8acaefade8995d7878616a8",
+      "priorNormalizedSha256": [
+        "3dc79b829d8ebf36186d42a8429fa33f10f5debb8eed5e910626cd614e32ce2e"
+      ],
+      "allowMissing": false,
+      "observedState": "static-file",
+      "observedSha256": "14c198eefc24883d75dd61a05cc2245c44da2f8cb8acaefade8995d7878616a8",
+      "changed": false,
+      "matchesKnownGitVersion": true
+    },
+    {
+      "path": "src/core/opportunity/score.js",
+      "sha256": "d10eaed160d1c7476b2424b3079b1f4a19e2ceb23be861b934b18b14df6df444",
+      "priorNormalizedSha256": [
+        "6b396005cce87ce42f15c85519401a7c9ae25c5b1e3972958fbd9138c2e7bff4"
+      ],
+      "allowMissing": false,
+      "observedState": "static-file",
+      "observedSha256": "6b396005cce87ce42f15c85519401a7c9ae25c5b1e3972958fbd9138c2e7bff4",
+      "changed": false,
+      "matchesKnownGitVersion": true
+    },
+    {
+      "path": "src/core/report.js",
+      "sha256": "206def0bcde21a0dbe9fb8e937f02fd035afaa268d022c2515e2249075615655",
+      "priorNormalizedSha256": [
+        "e253e94724acc02a406eb1e73e03341def57948a1fa65cf9c15fcae2b66b9d88"
+      ],
+      "allowMissing": false,
+      "observedState": "static-file",
+      "observedSha256": "206def0bcde21a0dbe9fb8e937f02fd035afaa268d022c2515e2249075615655",
+      "changed": false,
+      "matchesKnownGitVersion": true
+    },
+    {
+      "path": "src/core/taxonomy/index.js",
+      "sha256": "78be24599aeadb68138bc53c01931c80bb0a77e6671213ce8e717d4ebebbbc86",
+      "priorNormalizedSha256": [
+        "d870cb36ca4166df4c54e40150b5aa0b9cfe87850128c9ed381a0f257c9fceba"
+      ],
+      "allowMissing": false,
+      "observedState": "static-file",
+      "observedSha256": "d870cb36ca4166df4c54e40150b5aa0b9cfe87850128c9ed381a0f257c9fceba",
+      "changed": false,
+      "matchesKnownGitVersion": true
+    },
+    {
+      "path": "src/core/taxonomy/taxonomy.js",
+      "sha256": "ba01984956b28d4c2d7f6e863720342455e843192e8a4cba9bf38800adff27da",
+      "priorNormalizedSha256": [
+        "7b00ef1858becad5a7db6c0e67080c3f599770bc09d73f8f934973b4f4dad847"
+      ],
+      "allowMissing": false,
+      "observedState": "static-file",
+      "observedSha256": "7b00ef1858becad5a7db6c0e67080c3f599770bc09d73f8f934973b4f4dad847",
+      "changed": false,
+      "matchesKnownGitVersion": true
+    },
+    {
+      "path": "src/layout/detail-styles.js",
+      "sha256": "fc394d5aca8b57a920b7d40b16225a4039436deeb413c2434a42a79740f4b717",
+      "priorNormalizedSha256": [],
+      "allowMissing": true,
+      "observedState": "spa-fallback",
+      "observedSha256": "dfeb6a6d0b63e8e1190f9308bf6619a5ca523a5a2f91af5f7ca5ad5fc9642db3",
+      "changed": true,
+      "matchesKnownGitVersion": true
+    },
+    {
+      "path": "src/layout/kf-app.js",
+      "sha256": "c2de8dbc5ef980635f64a60fb9ccfa2551d16f4dd3bc89bc249a1bdc6ce324fb",
+      "priorNormalizedSha256": [
+        "6d46caede35a6a3d0175a519209c62d067b31d6cd8671de8749148475daf42cd"
+      ],
+      "allowMissing": false,
+      "observedState": "static-file",
+      "observedSha256": "4d0d140a0361bbafd75983862e0b2931962b91305238f43838573e3084c6ee6c",
+      "changed": true,
+      "matchesKnownGitVersion": false
+    },
+    {
+      "path": "src/layout/kf-nav.js",
+      "sha256": "305a0c85961b04cf1251bc8a1cd745b5ebb1cc18795c995a6fc2479c07019564",
+      "priorNormalizedSha256": [
+        "94d07288f19a7629c7aec67c9626ec0813038c5467b005fdecbf2f69e71ac943"
+      ],
+      "allowMissing": false,
+      "observedState": "static-file",
+      "observedSha256": "bf550dbea6596c612378bf219c0270032e967fd3399bc50bef11a46a39f9028c",
+      "changed": true,
+      "matchesKnownGitVersion": false
+    },
+    {
+      "path": "src/layout/kf-topbar.js",
+      "sha256": "d73f21fdcc7d894f383450d6ffbf130b5ddb5d8aef4a090c9adac99c38d054b4",
+      "priorNormalizedSha256": [
+        "57692d2401df957150317be3b233a13151a27d2ae5e98704ad25fcabbc84b45c"
+      ],
+      "allowMissing": false,
+      "observedState": "static-file",
+      "observedSha256": "d29b76bf0835f24d486569d410bb75531f476472aa4d0deb83a800430062140d",
+      "changed": true,
+      "matchesKnownGitVersion": false
+    },
+    {
+      "path": "src/pages/card-toolbar.js",
+      "sha256": "e30875b7e00148f23ab66c08c67b84ed4231540fbde30ed6f8e1ddeed3d6ed37",
+      "priorNormalizedSha256": [
+        "12de84574117e5357b81d4e79e68da721a9123782b18e62e1d8ad3dd07f7faec"
+      ],
+      "allowMissing": false,
+      "observedState": "static-file",
+      "observedSha256": "12de84574117e5357b81d4e79e68da721a9123782b18e62e1d8ad3dd07f7faec",
+      "changed": true,
+      "matchesKnownGitVersion": true
+    },
+    {
+      "path": "src/pages/filter-sheet.js",
+      "sha256": "21f6f54a91228965bbc61843ee588ac89830706ad89f4bfb68b5be7012217162",
+      "priorNormalizedSha256": [
+        "078af2def4dcfe4bf4369f100f38248bd8a3d0d283d69d0eb2011c7d91f79a35"
+      ],
+      "allowMissing": false,
+      "observedState": "static-file",
+      "observedSha256": "078af2def4dcfe4bf4369f100f38248bd8a3d0d283d69d0eb2011c7d91f79a35",
+      "changed": false,
+      "matchesKnownGitVersion": true
+    },
+    {
+      "path": "src/pages/library-product-detail.js",
+      "sha256": "8ece116e99c2cb0cf73d351201eb8690372ec8926b131cffe6ed15469d7663f2",
+      "priorNormalizedSha256": [],
+      "allowMissing": true,
+      "observedState": "spa-fallback",
+      "observedSha256": "dfeb6a6d0b63e8e1190f9308bf6619a5ca523a5a2f91af5f7ca5ad5fc9642db3",
+      "changed": true,
+      "matchesKnownGitVersion": true
+    },
+    {
+      "path": "src/pages/library-styles.js",
+      "sha256": "5c172da618f6694d5036b3d1ccc1d1286a8a5539744555ce0a678f02504b214c",
+      "priorNormalizedSha256": [],
+      "allowMissing": true,
+      "observedState": "spa-fallback",
+      "observedSha256": "dfeb6a6d0b63e8e1190f9308bf6619a5ca523a5a2f91af5f7ca5ad5fc9642db3",
+      "changed": true,
+      "matchesKnownGitVersion": true
+    },
+    {
+      "path": "src/pages/report-card.js",
+      "sha256": "ed315e52a9668e6167cf4dcf88f448e50ec56acc8ae5a756e8e77b43df70ce04",
+      "priorNormalizedSha256": [
+        "0a7a0922c39d5d976b95292c7dbe95fce36648673706c3324fc55a91149fc199"
+      ],
+      "allowMissing": false,
+      "observedState": "static-file",
+      "observedSha256": "0a7a0922c39d5d976b95292c7dbe95fce36648673706c3324fc55a91149fc199",
+      "changed": true,
+      "matchesKnownGitVersion": true
+    },
+    {
+      "path": "src/pages/report-pager.js",
+      "sha256": "b3933fbbd70364622d73a452118736fdeaa32d935227697e623b3a0e057b2fe9",
+      "priorNormalizedSha256": [],
+      "allowMissing": true,
+      "observedState": "spa-fallback",
+      "observedSha256": "dfeb6a6d0b63e8e1190f9308bf6619a5ca523a5a2f91af5f7ca5ad5fc9642db3",
+      "changed": true,
+      "matchesKnownGitVersion": true
+    },
+    {
+      "path": "src/pages/shared.js",
+      "sha256": "d93a52cd02e4035f5133480d133cd8c780bcc78ec1d67b9cc74efe49527bb9a9",
+      "priorNormalizedSha256": [
+        "3dabee7a5068c210f2c28172d987bd81dc50ed20e0c2134cf564164d24e5be3e"
+      ],
+      "allowMissing": false,
+      "observedState": "static-file",
+      "observedSha256": "3dabee7a5068c210f2c28172d987bd81dc50ed20e0c2134cf564164d24e5be3e",
+      "changed": false,
+      "matchesKnownGitVersion": true
+    },
+    {
+      "path": "src/pages/view-ledger.js",
+      "sha256": "a1c560647ff227155c9fd955a2e8a89dbdc4a50994417d101e647827fe3b57c7",
+      "priorNormalizedSha256": [
+        "e48a7fa6f41066bb190e1201645144e1c729a43af56746568e1ae1789f2f5879"
+      ],
+      "allowMissing": false,
+      "observedState": "static-file",
+      "observedSha256": "e48a7fa6f41066bb190e1201645144e1c729a43af56746568e1ae1789f2f5879",
+      "changed": true,
+      "matchesKnownGitVersion": true
+    },
+    {
+      "path": "src/pages/view-library.js",
+      "sha256": "0273d8ade5e3e3997473ca18ff00478b5fb8b3c9d47c54fe99b718a7137f2827",
+      "priorNormalizedSha256": [],
+      "allowMissing": true,
+      "observedState": "spa-fallback",
+      "observedSha256": "dfeb6a6d0b63e8e1190f9308bf6619a5ca523a5a2f91af5f7ca5ad5fc9642db3",
+      "changed": true,
+      "matchesKnownGitVersion": true
+    },
+    {
+      "path": "src/pages/view-members.js",
+      "sha256": "5b0b3c370b09899f4332e51b39d9c29cb58def9ad9734b579e90c3f76f6b8b2f",
+      "priorNormalizedSha256": [
+        "dc83a4b5deab6282819b2587080661173410390190f47254d9ab63ff6b97f8a2"
+      ],
+      "allowMissing": false,
+      "observedState": "static-file",
+      "observedSha256": "c31e3a4d09f7b519571c914dba75c5cbef995457ba7fd221c7eae610ea9eb9e3",
+      "changed": true,
+      "matchesKnownGitVersion": false
+    },
+    {
+      "path": "src/pages/view-pool.js",
+      "sha256": "4c5787ac63c5c44c2ef7709beadd8c931ad097f0ab4248adcfce16771afd27fa",
+      "priorNormalizedSha256": [
+        "bf3d3b1d8c6f388499f868188c135fe4d370ac8012c31ee2e5586892874611af"
+      ],
+      "allowMissing": false,
+      "observedState": "static-file",
+      "observedSha256": "ed3c4b5f0d3ccf50e3d23bfefb9f3bb50852c2620a5798bc0c3a7099819f6c1c",
+      "changed": true,
+      "matchesKnownGitVersion": false
+    },
+    {
+      "path": "src/pages/view-report-type.js",
+      "sha256": "fad00cacb63da7d84f7ce4597c31898ba20dbf41755199c08b7b17764f05bf8b",
+      "priorNormalizedSha256": [
+        "accef531cd2868272aae75b36e0828bd38253d42ab6c1f1520b04b25036758e3"
+      ],
+      "allowMissing": false,
+      "observedState": "static-file",
+      "observedSha256": "d6f43d2f425e54cf4898945e5cc2254341ef0216b35fecc5d9978334e87002b9",
+      "changed": true,
+      "matchesKnownGitVersion": false
+    },
+    {
+      "path": "src/pages/view-report.js",
+      "sha256": "dfda037af5817b3eb39b4675bab931574bdae00bc9e951634d345a5057eefe86",
+      "priorNormalizedSha256": [
+        "3a5c2f35961ed8816d3cdd61a9f713a5d8187f4b513ad075067c5ae15997276d"
+      ],
+      "allowMissing": false,
+      "observedState": "static-file",
+      "observedSha256": "3a5c2f35961ed8816d3cdd61a9f713a5d8187f4b513ad075067c5ae15997276d",
+      "changed": true,
+      "matchesKnownGitVersion": true
+    },
+    {
+      "path": "src/pages/view-reports.js",
+      "sha256": "0e7232de661002498ae363ee6e24f277f66cd36c6544254c8281a85898e79b1d",
+      "priorNormalizedSha256": [
+        "b744c5e7983d8ead47ce00bd9ddb012d7a8bebb1e17bca3ba5566e3f7ec24dd9"
+      ],
+      "allowMissing": false,
+      "observedState": "static-file",
+      "observedSha256": "42d56532ff79e930711c1c31dcea1f4e310e92aa06766d34653ff10027fcefa1",
+      "changed": true,
+      "matchesKnownGitVersion": false
+    },
+    {
+      "path": "src/pages/view-stages.js",
+      "sha256": "626797c2902de8fef353a1daf94ee29a30c6af656cb6f64070a5bfffa3eb96fb",
+      "priorNormalizedSha256": [
+        "2df37ad881c024e0df06304d893877d099300926579c238bd38211270ee503eb"
+      ],
+      "allowMissing": false,
+      "observedState": "static-file",
+      "observedSha256": "2df37ad881c024e0df06304d893877d099300926579c238bd38211270ee503eb",
+      "changed": true,
+      "matchesKnownGitVersion": true
+    },
+    {
+      "path": "src/pages/view-workbench.js",
+      "sha256": "8fc2d042439b52ea557098d58e68378b326ca6e4f2144002cb407c19e463b231",
+      "priorNormalizedSha256": [
+        "42f666c14d1427d6d6830de3a6eb2faf32ef50289ceab71e0eb4ed58630308d8"
+      ],
+      "allowMissing": false,
+      "observedState": "static-file",
+      "observedSha256": "42f666c14d1427d6d6830de3a6eb2faf32ef50289ceab71e0eb4ed58630308d8",
+      "changed": true,
+      "matchesKnownGitVersion": true
+    },
+    {
+      "path": "src/services/board-service.js",
+      "sha256": "2c1ecd438be53a5fee56e7255d7a06213dbe6ca2e0a0e827543608e62990146b",
+      "priorNormalizedSha256": [
+        "7d7f9e87a0b210e5083b388f492de0968f20bf3193fe6a3f9400005def10ead2"
+      ],
+      "allowMissing": false,
+      "observedState": "static-file",
+      "observedSha256": "2c1ecd438be53a5fee56e7255d7a06213dbe6ca2e0a0e827543608e62990146b",
+      "changed": false,
+      "matchesKnownGitVersion": true
+    },
+    {
+      "path": "src/services/library-service.js",
+      "sha256": "12d6500fc65fe828c7339ed108d1b551985eda3bdaf2b85d10b02a5ff94e2ea7",
+      "priorNormalizedSha256": [],
+      "allowMissing": true,
+      "observedState": "spa-fallback",
+      "observedSha256": "dfeb6a6d0b63e8e1190f9308bf6619a5ca523a5a2f91af5f7ca5ad5fc9642db3",
+      "changed": true,
+      "matchesKnownGitVersion": true
+    },
+    {
+      "path": "src/services/member-groups.js",
+      "sha256": "02a9c39d9f3d90ad47df06619f31181d1661956c9f46e0d46b8f9b4b3693df28",
+      "priorNormalizedSha256": [],
+      "allowMissing": true,
+      "observedState": "spa-fallback",
+      "observedSha256": "dfeb6a6d0b63e8e1190f9308bf6619a5ca523a5a2f91af5f7ca5ad5fc9642db3",
+      "changed": true,
+      "matchesKnownGitVersion": true
+    },
+    {
+      "path": "src/services/workshop-data.js",
+      "sha256": "c0abee4bc2f331569c6d9d7ab37f9941bfe2d3dd409bdea115778137be16af9e",
+      "priorNormalizedSha256": [
+        "cae7e9065a37d395429d5f1bd254b2201ad4b0218c6222349029455dc96a7c65"
+      ],
+      "allowMissing": false,
+      "observedState": "static-file",
+      "observedSha256": "cae7e9065a37d395429d5f1bd254b2201ad4b0218c6222349029455dc96a7c65",
+      "changed": false,
+      "matchesKnownGitVersion": true
+    },
+    {
+      "path": "src/styles/tokens.js",
+      "sha256": "cafec263fd40b78f8fbe5f6d3da46ef1f089fa9e308467e3b811d9d88baba118",
+      "priorNormalizedSha256": [
+        "9eea72c1313255545caa4a879d49fa5ee7826b5c7fca256c24eb5cc3ea0a3507"
+      ],
+      "allowMissing": false,
+      "observedState": "static-file",
+      "observedSha256": "cafec263fd40b78f8fbe5f6d3da46ef1f089fa9e308467e3b811d9d88baba118",
+      "changed": false,
+      "matchesKnownGitVersion": true
+    },
+    {
+      "path": "src/utils/card-view.js",
+      "sha256": "beec0ad6768dc44d0a73b091ca59241d5e6042b7598310df6ef69a34478f2a1a",
+      "priorNormalizedSha256": [
+        "6f6da743ac5e87b717485496d568264c8e3fc104302c0aa433f66de5476613a9"
+      ],
+      "allowMissing": false,
+      "observedState": "static-file",
+      "observedSha256": "6f6da743ac5e87b717485496d568264c8e3fc104302c0aa433f66de5476613a9",
+      "changed": true,
+      "matchesKnownGitVersion": true
+    },
+    {
+      "path": "src/utils/cjk-deep.js",
+      "sha256": "8a545389997937eb1df39928e9c59d8d967716e862e1c79b60756d6f4a9c9029",
+      "priorNormalizedSha256": [
+        "9d973b2142e06a1c3c3320f6105d0efe6d07f8efc6c3ca96cbe1978957f302b9"
+      ],
+      "allowMissing": false,
+      "observedState": "static-file",
+      "observedSha256": "9d973b2142e06a1c3c3320f6105d0efe6d07f8efc6c3ca96cbe1978957f302b9",
+      "changed": false,
+      "matchesKnownGitVersion": true
+    },
+    {
+      "path": "src/utils/http.js",
+      "sha256": "627444b3614136bfb11999cb73e37a4e95f4bd950711c6128c59ef005c848504",
+      "priorNormalizedSha256": [
+        "f21d4ccc639c13feff3c55e9239b523ecc38c20d9f8b1fff08db3340b6e6d732"
+      ],
+      "allowMissing": false,
+      "observedState": "static-file",
+      "observedSha256": "627444b3614136bfb11999cb73e37a4e95f4bd950711c6128c59ef005c848504",
+      "changed": false,
+      "matchesKnownGitVersion": true
+    },
+    {
+      "path": "src/utils/router.js",
+      "sha256": "4ca4a3a0a028750e58ed9fdd378348cd7ee174e0d4026fe541cd5b862c474048",
+      "priorNormalizedSha256": [
+        "f3645a69c06ac9819454acc6a4d9febf5ffbc451b65d985a02f1042bada7f82b"
+      ],
+      "allowMissing": false,
+      "observedState": "static-file",
+      "observedSha256": "b0cc1e555e55f137f5d3bd0772a2fa027b39419aa3fb0bb72a5be9b2d51b5a51",
+      "changed": true,
+      "matchesKnownGitVersion": false
+    },
+    {
+      "path": "src/utils/storage.js",
+      "sha256": "4212824a06c37bfc87b2340a487185779c1ae679f344ee491973ea0c59685b2a",
+      "priorNormalizedSha256": [
+        "b614463cdeed015327a7851eac6bd43bf95db14c16fe13f094e8af5cec545026"
+      ],
+      "allowMissing": false,
+      "observedState": "static-file",
+      "observedSha256": "b614463cdeed015327a7851eac6bd43bf95db14c16fe13f094e8af5cec545026",
+      "changed": false,
+      "matchesKnownGitVersion": true
+    },
+    {
+      "path": "src/utils/type.js",
+      "sha256": "1577a56abf65a0768e1e08ca3d1b4cb7545effb2527296cea8d4a7a0992c05d7",
+      "priorNormalizedSha256": [
+        "5bc6a7bf164b38961ada06ca9c4c1b334b4679d9e227fadee0b80b8308bae165"
+      ],
+      "allowMissing": false,
+      "observedState": "static-file",
+      "observedSha256": "5bc6a7bf164b38961ada06ca9c4c1b334b4679d9e227fadee0b80b8308bae165",
+      "changed": false,
+      "matchesKnownGitVersion": true
+    }
+  ]
+}

+ 21 - 0
deploy/complete-release-plan.json

@@ -0,0 +1,21 @@
+{
+  "schema": "ksf.complete-release-plan.v1",
+  "release": "ksf-complete-deploy-20261106-r1",
+  "origin": "https://kangshifu.fmode.cn",
+  "previousPushedCommits": [
+    {"commit": "d14d02fd358c6c5dedfabc96c53fb29add8bd021", "scope": "workshop data and project progress"},
+    {"commit": "bb38e3732e8ae102ff98345abfee5c1caba13d71", "scope": "industry library, member grouping and board interactions"},
+    {"commit": "0ba3c0a07afb70aed54713739d3c128dacc1aaf0", "scope": "compact product details, review count and price sorting"}
+  ],
+  "components": [
+    {"id": "board-static", "action": "publish-whitelist", "target": "/opt/kangshifu-web", "manifest": "deploy/board-files-20261106.json", "homepageModified": false},
+    {"id": "member-groups-functions", "action": "create-if-missing-or-verify-same", "names": ["ksfGetPublicMemberGroups", "ksfManagePublicMemberGroups"]},
+    {"id": "library-service", "action": "deploy-private-readonly-service", "entry": "server/library-api.mjs", "url": "/api/library", "requiresAuthorizedGateway": true},
+    {"id": "library-database", "action": "install-verified-private-snapshot", "archive": "ksf-voc-database-20261106-r1.zip", "sha256": "1ba1ac2fa6a8b774f8945d88e014c07dd5e03332792af298a41f015ba6946f9f", "databaseSha256": "944e4bb3a5d65dd897ea7c912e1af001aa845db1ba1f968a7b0ee653dc60fa50", "generation": "7e0df42395d9d2ea39dd5b537d17ae35ac910cc29721ed92a680b7fe6b25c92c"},
+    {"id": "query-skills", "action": "verify-installed-same-or-use-existing-upgrade-package", "agentRulesVersion": "ksf.agent-data-rules.20261106.v1", "manifests": {"ksf-voc-library": "4da410bb882dc73e277c740d13407918d9c9d5da2196ac9211f6ff7c009692fb", "ksf-workshop-runtime": "d66e50547f04d761265dec529106d0efbd2c1451001d02857af866f29f5f4fe4"}},
+    {"id": "deployed-identity-and-bridge", "action": "verify-preserved-existing-hotfixes", "rewriteExistingFunctions": false}
+  ],
+  "excludedFromActivation": ["historical data collection and ingestion workflows", "retired demo routes", "developer checks", "private credentials"],
+  "completionRequires": ["static external hash readback", "member groups runtime read and controlled management check", "private database contract and source readback", "authorized same-origin library queries", "installed skill manifests", "production browser verification"],
+  "productionInstalled": false
+}

+ 17 - 0
deploy/complete-release-validation.json

@@ -0,0 +1,17 @@
+{
+  "schema": "ksf.complete-release-validation.v1",
+  "checkedAt": "2026-11-06",
+  "nodeTests": {"total": 177, "passed": 177, "failed": 0, "skipped": 0},
+  "databaseAndInstallerPythonTests": {"total": 65, "passed": 65, "failed": 0, "skipped": 0, "privateCorpusTested": true},
+  "identityRegistrationPythonTests": {"total": 9, "passed": 9, "failed": 0},
+  "bridgeInstallerPythonTests": {"total": 3, "passed": 3, "failed": 0},
+  "totalTestsPassed": 254,
+  "staticCheck": "CHECK OK",
+  "skillManifests": {"ksf-voc-library": "9/9", "ksf-workshop-runtime": "10/10"},
+  "literalCredentialScan": "no findings in non-fixture source",
+  "whitespaceCheck": "passed; preserved CRLF skills use cr-at-eol",
+  "databaseTransport": "two attachments under 20MB, exact reconstruction, corruption rejection and repeat no-op verified",
+  "productionInstalled": false,
+  "browserVerified": false,
+  "productionWrites": 0
+}

+ 55 - 0
deploy/opportunity-ui-files-20261103.json

@@ -0,0 +1,55 @@
+{
+  "version": "ksf.opportunity-ui.publish.v1",
+  "release": "ksf.opportunity-ui.20261103.v1",
+  "homepageSha256": "dfeb6a6d0b63e8e1190f9308bf6619a5ca523a5a2f91af5f7ca5ad5fc9642db3",
+  "files": [
+    {
+      "path": "src/components/kf-opportunity-card.js",
+      "sha256": "feacdb73b680c3292e6742ed17bf2a199d52ae8d161376df27d0dd86ed6a1975",
+      "priorNormalizedSha256": [
+        "65352c4be0d5297f04dafd2a12cf7ceff440e30a0ee7e27c83718d77255b9a60"
+      ],
+      "allowMissing": false
+    },
+    {
+      "path": "src/pages/view-pool.js",
+      "sha256": "bf3d3b1d8c6f388499f868188c135fe4d370ac8012c31ee2e5586892874611af",
+      "priorNormalizedSha256": [
+        "5b2e6265a340df09480d219af67780ae4d3c1e5f6cc5d9eff5877562521cfa1e"
+      ],
+      "allowMissing": false
+    },
+    {
+      "path": "src/utils/router.js",
+      "sha256": "32bb5ce86da922e14f0aa4f34dd8fbaef60a13588e7d564f6476c58fc5355cad",
+      "priorNormalizedSha256": [
+        "c234103a6585ac40347214ba1fe27ff2e36316d55b4e82b074478254d88561f4"
+      ],
+      "allowMissing": false
+    },
+    {
+      "path": "src/services/board-service.js",
+      "sha256": "7d7f9e87a0b210e5083b388f492de0968f20bf3193fe6a3f9400005def10ead2",
+      "priorNormalizedSha256": [
+        "cd2135fc5ec7cfff06a69583ab015430c9318f14af87c8f2c9605bcc748d5ef5"
+      ],
+      "allowMissing": false
+    },
+    {
+      "path": "src/services/workshop-data.js",
+      "sha256": "8b97ff1f55f7f7df0c886009478b0403bbd456089190f8f9bb2595c3f4b5dc46",
+      "priorNormalizedSha256": [
+        "b481e267bb556687c4562f54a1ef1e57862a74767df8fecd20e0c8327fa384da"
+      ],
+      "allowMissing": false
+    },
+    {
+      "path": "src/layout/kf-app.js",
+      "sha256": "ac936167c2820fc2cd8a122cd3686ad741bfde2b7628f59bd1e6de157df77272",
+      "priorNormalizedSha256": [
+        "542300c6a5d261e5b49b31a739908a89627f13f7263e178c69bd519b8a60f544"
+      ],
+      "allowMissing": false
+    }
+  ]
+}

+ 32 - 0
deploy/report-evidence-files-20261102.json

@@ -0,0 +1,32 @@
+{
+  "version": "ksf.report-evidence.publish.v1",
+  "release": "ksf.report-evidence.20261102.v1",
+  "homepageSha256": "dfeb6a6d0b63e8e1190f9308bf6619a5ca523a5a2f91af5f7ca5ad5fc9642db3",
+  "baselineObservedAt": "2026-11-02T09:38:31.982Z",
+  "files": [
+    {
+      "path": "src/core/report.js",
+      "sha256": "206def0bcde21a0dbe9fb8e937f02fd035afaa268d022c2515e2249075615655",
+      "priorNormalizedSha256": [
+        "fa621766d171b8143b39a0b43cbbaf9299e2b2c50b744b042ea2d5522d94e7ca"
+      ],
+      "allowMissing": false
+    },
+    {
+      "path": "src/core/evidence/chain.js",
+      "sha256": "3798974a75fa078cc2c6cef30651bb250917b35fa79625f90d3e89df401a9fb3",
+      "priorNormalizedSha256": [
+        "04fff4bf4010a866d370daba648ccb4a27aee178244d1ccb8e5f0dff51270ca6"
+      ],
+      "allowMissing": false
+    },
+    {
+      "path": "src/layout/kf-app.js",
+      "sha256": "baedd7b67b075c9dbb22f33a6f5a9299133c026d6325eabdfe0344586d52e782",
+      "priorNormalizedSha256": [
+        "c80a33e98acaf9be82f80ed1e6cc53cf6c277b193dbada8fbab8fc914e697efd"
+      ],
+      "allowMissing": false
+    }
+  ]
+}

+ 76 - 0
deploy/workshop-files-20261101.json

@@ -0,0 +1,76 @@
+{
+  "version": "ksf.workshop.publish.v1",
+  "commit": "d14d02fd358c6c5dedfabc96c53fb29add8bd021",
+  "homepageSha256": "dfeb6a6d0b63e8e1190f9308bf6619a5ca523a5a2f91af5f7ca5ad5fc9642db3",
+  "homepageObservationAt": "2026-11-01T08:03:19.761Z",
+  "files": [
+    {
+      "path": "src/services/workshop-data.js",
+      "sha256": "b481e267bb556687c4562f54a1ef1e57862a74767df8fecd20e0c8327fa384da",
+      "priorNormalizedSha256": [],
+      "allowMissing": true
+    },
+    {
+      "path": "lib/vendor/signal-polyfill/v0.2.2/dist/index.js",
+      "sha256": "eb9e97575cca78070eed9b53f4873fb8c7391946b305c709373c605806371187",
+      "priorNormalizedSha256": [],
+      "allowMissing": true
+    },
+    {
+      "path": "src/app.config.js",
+      "sha256": "e8798ef2a1f77d49f93c360f0b6b804bb94e22534ef60362a537918d592e6f37",
+      "priorNormalizedSha256": [
+        "5bfb21791073e757c94971aaa14891ad15916c8c4060ceb4397781323dea7aa3"
+      ],
+      "allowMissing": false
+    },
+    {
+      "path": "src/core/kfs/build.js",
+      "sha256": "6fb3c711c09c27d2505ca8bdc04392d38c80287901743f6d73c90b09c3f56df3",
+      "priorNormalizedSha256": [
+        "fa9a083a3a149ff5a7efffe6aeb11739b43ef1a80109c8136eba25f91e18f1bf"
+      ],
+      "allowMissing": false
+    },
+    {
+      "path": "src/core/library/reports.js",
+      "sha256": "e02a0faa4706bc7ded7b35443822e7ce2da9496553b2a13924d33ddee3aa0724",
+      "priorNormalizedSha256": [
+        "bf4ae3cebcefabb9c5c5e3d24875dee0fee079cadd5f2c5f9be3630abd22dd3c"
+      ],
+      "allowMissing": false
+    },
+    {
+      "path": "src/core/opportunity/opportunity.js",
+      "sha256": "445f968aa5b6d2e3f0eda9fcfa1cde7eb7a65fcfdb7f39c81a4b4f8ff2eb559a",
+      "priorNormalizedSha256": [
+        "0388fb157476a7eb6a26a46697e61566e0326e4e4f8201bfbdf88fb63ca2e3fc"
+      ],
+      "allowMissing": false
+    },
+    {
+      "path": "src/core/taxonomy/taxonomy.js",
+      "sha256": "7b00ef1858becad5a7db6c0e67080c3f599770bc09d73f8f934973b4f4dad847",
+      "priorNormalizedSha256": [
+        "e5cc15461220f650f44c056e6e127a17d4b30eafc3c6c09900c2b1295c601d4a"
+      ],
+      "allowMissing": false
+    },
+    {
+      "path": "src/pages/view-members.js",
+      "sha256": "95510ecc92b4751f7666287d5310088cb3f87d8250fbb53e3d58de8dcb9270ff",
+      "priorNormalizedSha256": [
+        "552bf38cd58f47ad1bf4ecf88ed4a49b3e2d840babe3e48890a23587f5c7ba45"
+      ],
+      "allowMissing": false
+    },
+    {
+      "path": "src/services/board-service.js",
+      "sha256": "cd2135fc5ec7cfff06a69583ab015430c9318f14af87c8f2c9605bcc748d5ef5",
+      "priorNormalizedSha256": [
+        "3aeff77ec88bf319d71a40be3f931bb5ebf92f68e5f81872c70fe8d0fc7c2ff1"
+      ],
+      "allowMissing": false
+    }
+  ]
+}

+ 35 - 0
docs/README.md

@@ -0,0 +1,35 @@
+# 工作坊文档入口
+
+## 当前完整发布 · 2026-11-06
+
+本轮统一发布入口见 [完整发布执行单](board-deploy-20261106/完整发布执行单.md),机器清单见 [完整发布计划](../deploy/complete-release-plan.json)。此前工作坊提交d14d02f和看板提交bb38e37、0ba3c0a已推送;新版查询合同、两份Skill及安装/验收/交付源码随本轮补齐。正式发布以完整包RELEASE.json中的sourceCommit为准,不逐个旧包叠加。
+
+Skill源码在skills/,私有数据库、ZIP交付包、采集资料、凭据与回执不进Git。数据库投递见 [两个小于20MB的分包说明](voc-database-delivery-20261106/发给运维001-数据库分包投递.md)。当前远端数据库尚缺实际安装回读;生产发布和浏览器验收须等运维真实回执,以下历史记录不当作本轮上线证明。
+
+本地看板统一使用 http://127.0.0.1:4181/,前端源码统一维护在主目录。启动和页面说明见 [本地统一预览](local-preview.md)。旧端口 4185、4177 已停用。
+
+2026年11月2日整理。日常使用的文档已集中到F:\文档,仓库保留开发、打包和复核所需技术资料。
+
+## 当前交付位置
+
+| 用途 | 位置 |
+|---|---|
+| 新数字人手动操作、角色、手册、运维接入及验收 | F:\文档\新数字人交接 |
+| 最新文字汇报及后续待补资料 | F:\文档\工作坊当前资料 |
+| 旧准备表、旧汇报、重复说明及旧版附件 | F:\文档\工作坊历史归档\20261102-文档整理前旧稿.zip |
+| 整理前后路径与逐文件指纹 | F:\文档\工作坊历史归档\20261102-文档整理清单.json |
+
+新数字人文档打包脚本已改用新位置,不再向旧docs/new-workshop-agent-20261102生成副本。交接包只含文档,安装原包仍按材料索引取用。
+
+## 保留的技术资料
+
+- workshop-real-message-progress-20261102.md:按时间追加的验收记录。前段是历史准备状态,最新结果见后续更新,不单独摘取早期“未完成”表述。
+- workshop-integration.md、workshop-identity.md、wecom-aibot-identity-handoff.md、wecom-current-employee-registration.md:接口与身份技术资料,供源码维护和打包器使用,历史部署状态不作为当前上线结论。
+- wecom-tool-result-hotfix-20261102.md:返回值格式修复说明。
+- workshop-dedup-20261102.md、workshop-dedup-acceptance-20261102.md:查重设计与独立验收说明。
+- workshop-today-full-chain-20261102.md:原样例完整流程说明,固定旧任务键不可重放。
+- first-live-sources-20261102.md、first-live-S1-20261102.md:真实资料采集与S1产物准备、回读约定;执行结果看最新验收记录。
+- aibot-adapter-v3-review-20261102.md:仅为旧打包器复现保留的历史输入,不是当前推荐安装版本。当前组合为v4适配器+查重版Skill+返回格式热修。
+- 已有v11—v16页面设计记录与截图:版本库和页面验证脚本仍引用,作为工程基线保留。
+
+安装原包及业务验收附件保留在work/delivery-by-recipient-20261102;采集原件、只读验收JSON及截图仍保留在work相应位置。本轮没有清理业务记录、凭据或运行中的服务。

+ 49 - 0
docs/aibot-adapter-v3-review-20261102.md

@@ -0,0 +1,49 @@
+# aibot 身份补丁 v3 复核 · 2026-11-02
+
+结论:v3 已修正 v2 的主要逻辑问题,可以继续隔离合并;仍不满足安装验收。新发现实例登记会被消息清理误删,且工具到 Skill 的登记此前尚未连接。我方本轮已补直接登记代码、关闭旧自动 JSON CLI,并提供运维合并件;没有生产安装或远端写入。
+
+## 输入指纹
+
+- tgz:55,363 字节,SHA-256 32e84abeb3909651208b4c35e9e95fa2a75a68d41652471a0777e3f0256613ae。
+- 修订说明:4,890 字节,SHA-256 4f2f41c6b72bb8bdbdf5355d394a587b9552e8145819a69761002d3af34c7704。
+- tgz 隔离提取,普通文件16项;MANIFEST.sha256 覆盖15项,逐一重算通过。README 的14项是过期说明,实际为15项;这是文档数量问题,非本次哈希失败。
+
+## 已确认的改进
+
+strict_session_reader 不再调用 get_session_env 或读取会话环境变量,直接读取 _VAR_MAP 中的 ContextVar;单聊使用有效会话键比较;原始 chattype 直接进入严格校验;botId 来自 adapter 初始化登记,不再正则猜配置;拒绝 wecom_callback;新增独立插件入口;清单脚本生成及校验通过。
+
+本机独立合成上下文复验:残留 env 不能填补空 ContextVar;原始单聊 chatId=null、会话 chat_id=senderId 时通过;两个重叠线程使用同一份 ContextVar 映射,各取到自己的消息。原始文件读取为模拟,未声称完成真实网关/POSIX验证。证据见 work/adapter-v3-independent-review-20261102.json。
+
+## 新发现:消息清理误删实例登记(安装前修)
+
+aibot_identity.sweep() 将所有 .json 文件按六小时 mtime 清理,包括 instance.json。该文件只在 adapter 初始化时写一次;进程运行超过六小时后,新消息 write_context 调用 sweep,会删掉实例登记;工具随后报 INSTANCE_REGISTRY_MISSING,直到重新初始化。属于正常持续运行会触发的故障。
+
+本机用真实隔离文件复现:写实例登记→调旧 mtime 为六小时以上→写入新消息→instance.json 不存在。没有修改生产或附件原源码。修订建议是仅清理64hex消息文件名,排除/分离实例登记,补持续运行回归测试。
+
+## 测试与接口仍需明确
+
+test_production_reader.py 中线程每次 start 后立即 join,实际顺序执行,不能称作并发隔离验证。我方补了共享 ContextVar 映射、屏障确保重叠的检查,合成环境通过。请并入正式 Linux 夹具。
+
+插件入口已补齐,但 test_plugin_wiring 用假 PluginContext;raw_frame 测试复制适配器推导,不实际运行其消息处理。它们能证明离线连接逻辑,不能证明真实工具 API、handler 参数约定或线程继承。仍需最小运行时源码核对及获准后真实消息试点。缺少 _VAR_MAP 时目前能失败关闭,不能假定所有 Hermes 0.21.3 构建都相同。
+
+严格 reader 若 ContextVar.get() 抛 LookupError 或变量对象异常,应返回固定失败码,而非让原异常穿过查询 handler。当前登记 handler 会统一截获并脱敏,但诊断查询也应稳定失败。请补该用例。
+
+instance.json 是同 UID 可写的登记文件,不构成不可伪造 bot 身份;本轮接口防止模型通过工具参数替换身份,不隔离任意同 UID 终端代码。单 HERMES_HOME 单活动 bot 是当前限制。服务端 identityBasis/scope 一致性检查不等同独立验证企微来源,README 的“服务端独立复核”须限定为字段检查。
+
+## 我方本轮完成
+
+新增无参数进程内工具 ksf_register_current_employee,其直接调用 identity_bridge.current_identity(),核对维护人配置的 botId 指纹和稳定 Agent 档案键,再用私有 stdin 调 Node 档案模块;返回脱敏档案键,不经模型转述身份。
+
+复用现有 Skill 规范键、标签、作用域与档案代码;旧 register-channel-employee 全部形式关闭,通用 register-employee 拒绝通道来源/aibot账号,避免改走普通输入复述。内部 worker 明确不证明来源。Skill 清单更新为10项,安装器保留并认可已知旧基线;旧交付包不覆盖。详情见 docs/wecom-current-employee-registration.md。
+
+本机测试:工作坊 Node 套件60/60通过;新增 Python 登记测试7/7通过,包括真实Python→Node→临时档案、重复复用、缺上下文不启动worker、错bot拒绝、非空工具参数拒绝、worker错误不泄露。合成消息/ContextVar,不是企微端到端实测。运维自报Linux34/34未在本机作为POSIX测试重复执行,不混称独立通过。
+
+安装器另在本机临时目录用此前已发王刚001包模拟升级,凭据绑定与状态样例保持,第二次运行changed=[]。该测试未安装任何真实数字人,未使用真实凭据。Skill格式校验通过;最终隔离交付包名为ksf-current-employee-integration-prep-20261102-v2.zip,v1为本机打包中间件,未作为最终交付。
+
+## 下一步
+
+运维001先在隔离副本修清理问题,接入我方登记工具和组合入口,补真实并发及异常reader测试,核对运行时最小源码;将隔离Linux合并测试结果回传。不安装、不部署、不重启、不轮换、不触发客户消息。
+
+王刚001继续等待,不额外探测、不装旧包、不人工登记、不跑K轮。它已安装Skill停在旧expectedWorkshop基线,之前“未安装”指准备中的升级包;不应理解为完全没有旧Skill。
+
+两边准备通过后才能安排单实例安装/真实消息/远端合同核对,再验员工→Agent→会话→成果→公开看板。凭据轮换另走受控窗口,不转发旧值、不承诺断连策略和恢复时长。本轮没有联系两个数字人,转发文件由用户发送。

+ 107 - 0
docs/board-deploy-20261106/发给运维001-部署已推送看板.md

@@ -0,0 +1,107 @@
+# 运维001:部署已推送的康师傅看板
+
+本次已授权部署,不用再次询问是否发布。将 `main` 已推送提交 **0ba3c0a07afb70aed54713739d3c128dacc1aaf0** 部署至 **https://kangshifu.fmode.cn**,站点目录先核实为 `/opt/kangshifu-web`。本包已冻结此提交,不能从当前开发工作区直接全量拷贝;后续未提交的Agent规则不在本次范围。
+
+请把随附看板包与已有 **ksf-voc-database-20261106-r1.zip** 一起使用。部署包、服务代码、数据库、回执均放站点外;仅白名单 `payload/src/` 文件进入站点。不将整个压缩包解进Web目录。
+
+## 1. 目标功能与现状
+
+- 机会卡、关联成果、关联报告居中详情;完整报告排版、分页、前进后退、关闭返回修复。
+- 证据台账横向布局、顶部遮挡标题清理、桌面和手机布局调整。
+- 成员页按钮打开分组管理弹窗,支持增组、改名、删组、添加未分组成员、移除成员。
+- 行业资料库以商品为入口查看评论、规格、价格等;政策文件和条款独立浏览;按平台/品牌/品类等维度精确筛选。
+- 评论数量和价格只排序;不恢复文本搜索、更多筛选、评论日期/价格日期区间;商品详情使用紧凑布局。
+- 移除演示导航,旧演示路由返回工作台;保留已上线的本人自报昵称及未核验提示。
+
+Codex本次只读核实:现网仍是旧版;`ksfGetPublicMemberGroups` 返回404,`POST /api/library` version查询返回404。**须部署必要后端,静态上传不等于这些功能已可用。** 未执行任何生产写入,也未执行生产浏览器验收。
+
+## 2. 核验、备份和固定范围
+
+1. ZIP字节/SHA以外附 `delivery-receipt.json` 为准。解包后执行 `sha256sum -c FILES.sha256`。核 `RELEASE.json` 提交、71个静态文件、2个新增函数及数据库依赖指纹。
+2. 使用受控目录,例如 `/opt/ksf-board-release-20261106-r1`。回执单独放 `/opt/ksf-board-deploy-receipts-20261106`,0700目录/0600文件。保留原nginx有效配置、进程管理配置、站点白名单文件、函数清单和六个身份/工作坊函数的代码指纹。不要读取或输出密钥值。
+3. 首页不在本包。它当前SHA256应为 `dfeb6a6d0b63e8e1190f9308bf6619a5ca523a5a2f91af5f7ca5ad5fc9642db3`;不上传开发首页,不运行默认带 `/kangshifu/` 前缀的配置生成脚本。生产仍部署在根路径 `/`,保留现有vendor/importmap。
+4. 原 `/src/core/kfs/employee.js` 已与本版相同,SHA为 `113fa23092992186eec9104a6c0af4bdb7b323579e43a11421ea4d6193d38b5d`。不要用旧发布包回退昵称修复。
+5. 不重新部署现有工作坊/身份六函数、不安装Skill/平台桥、不新建数字人、不重启Agent、不重存报告、不采集新数据。
+
+## 3. 成员分组后端:只新增两个函数
+
+`payload/server/dist/member-groups/manifest.json` 与两个 `.js` 已从固定提交构建、语法校验。只处理:
+
+| name / path | code SHA256 |
+|---|---|
+| ksfGetPublicMemberGroups / /ksfGetPublicMemberGroups | e0369fbf3dd6782e050aac0196b95c4ea79de3735d83fa3b48fd564fc7eed165 |
+| ksfManagePublicMemberGroups / /ksfManagePublicMemberGroups | ce65cdd6be46e1b27c6c0e59b322c34ca577903a99bb4aff2c811dea57ab7904 |
+
+复用现有平台受控管理连接 `/opt/ksf-identity-private/platform.json`,用已核实的Function发布流程先dry再apply一次。包内没有新的云端凭据读取器;不要运行旧全函数构建/发布器。按name和path联合查重:不存在才新增;同SHA则no-op;发现不同代码或重复记录,保留现场并报告,不能覆盖猜测。Function记录ACL沿用已部署同类公开看板动作函数的受控记录ACL,不放宽既有类ACL/Schema/业务授权。
+
+新增后按objectId独立回读 name/path/code SHA。再调用只读 `ksfGetPublicMemberGroups`,确认真实成员和revision,不把查询失败当空成员。原机会卡动作函数 `ksfSetPublicOpportunityStatus` / `ksfRatePublicOpportunity` 只核存在和可用性;如缺失报告具体项,不为了测试去改实际机会卡。
+
+分组写入只涉及 `KsfRuntimeConfig.key=public-board-member-groups` 和操作审计,不改工作坊成员身份、权限、成果归属。测试分组管理时,只建一个明确标记的临时测试组,使用当前revision与独立requestId;增删成员须使用真实稳定成员ID,测试完成还原原分组并删除测试组,留回执,不能修改称呼或会话。如不执行这项写验收,明确记管理写入待验,不能只凭查询成功宣布全功能通过。
+
+## 4. 行业资料库:生产私有数据库 + 只读服务 + 同源授权入口
+
+### 数据库
+
+确认**看板服务实际运行宿主**,不能把王刚容器里的同名路径当作宿主共享挂载。用同一数据库补包,在该服务可读的私有目录安装本次快照;目录0700/库0600、属主为实际服务用户,不使用chmod全员可读,不放 `/opt/kangshifu-web`。
+
+补包解到站点外后,按运行用户执行(示例数据库目标为 `/opt/ksf-voc-library`,如已有库/registry先核实,不覆盖):
+
+```sh
+python3 -B /opt/ksf-voc-database-delivery-20261106/install-voc-database.py --package /opt/ksf-voc-database-delivery-20261106 --target-dir /opt/ksf-voc-library --check
+python3 -B /opt/ksf-voc-database-delivery-20261106/install-voc-database.py --package /opt/ksf-voc-database-delivery-20261106 --target-dir /opt/ksf-voc-library --apply
+```
+
+数据库 98,918,400字节,SHA `944e4bb3a5d65dd897ea7c912e1af001aa845db1ba1f968a7b0ee653dc60fa50`,generation `7e0df42395d9d2ea39dd5b537d17ae35ac910cc29721ed92a680b7fe6b25c92c`。原始数量为商品4639、评价2360、价格4070、规格4639、社媒3814、政策25、条款31;analysis可用数与总记录数分别报告。
+
+这是独立静态快照。与王刚的库同SHA/代际才可称本次对齐,不代表共享存储或未来自动更新。已有真实registry则显式核验两端当前指针/代际/SHA,不能编一个current.json路径。
+
+### 服务与授权代理
+
+服务代码为 `payload/server/library-api.mjs` 与 `payload/server/voc-library/`,Node>=20、Python>=3.11且SQLite有FTS5;不需npm/pip安装。部署至站点外,由既有进程管理器持久托管:
+
+```sh
+node /opt/ksf-board-release-20261106-r1/payload/server/library-api.mjs
+```
+
+由运维在该进程受控环境文件中配置(0600,值不进入聊天/仓库/静态):
+
+| 环境项 | 配置 |
+|---|---|
+| KSF_VOC_DB | 实际私有library.sqlite绝对路径,例如 /opt/ksf-voc-library/library.sqlite |
+| KSF_LIBRARY_REGISTRY | 若已有已核实registry才使用;与独立db二选一,避免旧registry优先 |
+| KSF_LIBRARY_PYTHON | 实际python3绝对路径 |
+| KSF_LIBRARY_PORT | 经核未占用的本机端口,例如4180 |
+| KSF_LIBRARY_API_TOKEN | 运维生成并受控存放的服务间token,不给前端 |
+
+服务仅绑定127.0.0.1。生产同源 **POST /api/library** 经现有受控访问入口校验浏览者权限,再在服务端代理到此服务并携带Bearer。浏览器请求本身不携带此服务token。保留其余nginx规则,`nginx -t`通过后再reload。
+
+**先核实现有授权方式:本包没有新建员工登录系统,也没有可直接照抄的通用认证nginx片段。若现网没有可复用的资料访问授权入口,保留401/403,明确回报“资料库访问授权入口待接通”,不要全网无条件注入Bearer或用localPreview免鉴权。该缺项独立列出,其他已具备条件的页面照常发布。** 不请求新通讯录凭据,不把资料库token放src、页面、URL或浏览器localStorage。
+
+先直接在本机受控调用summary/browse/detail,确认readOnly=true、databaseWrites=0、generation正确、商品→自身评论/价格/规格关系正确;再从已授权同源入口复验。缺授权访问应401/403,不能将接口的401/HTML兜底当作空库。
+
+## 5. 静态发布:先检查,再应用一次
+
+```sh
+node /opt/ksf-board-release-20261106-r1/scripts/publish-board.mjs --target-dir /opt/kangshifu-web
+node /opt/ksf-board-release-20261106-r1/scripts/publish-board.mjs --target-dir /opt/kangshifu-web --apply
+```
+
+第一条只读;白名单71项,按本次线上基线预计**35项改变,其中10项新增**,其余不重写。以现场真实计划为准;发现未知内容、首页不同或路径/软链冲突就停止该静态步骤,不改清单绕过检查。基线记录包括现网已部署热修;它们有些不是Git历史中的原始文件,不能用git reset覆盖源站。
+
+第二条先在站点外备份全部被替换文件,再按白名单发布;有并发变化则停止,失败自动尝试仅回退本次已写且未被别人修改的文件。回报backup实际路径、filesystemVerified与首页未变。工具不删除额外文件、不修改首页/vendor/nginx、不联网、不部署函数或数据库。不要把“文件发布成功”当成浏览器通过。
+
+## 6. 生产验收与回报
+
+1. 外网逐个GET白名单模块,核归一化SHA与清单一致、Content-Type正确。新JS返回200但内容为首页HTML时算失败。核首页SHA未变,匿名 `/api/library` 仍受授权保护。
+2. 浏览器打开工作台、卡池、五阶段、报告列表/类型/全文、证据台账、成员、行业资料库;对详情直接链接刷新、前进后退、关闭、从关联成果/报告打开逐项验收;旧演示路由回工作台。桌面1440/1024、手机390/320检查无横向溢出、标题遮挡、双重浮层,控制台无异常。
+3. 成员“管理分组”弹窗,不挤占成员页主体。核新增/改名/删组/添加未分组成员/移除成员;同名不同稳定ID不得合并,昵称“小智”及本人提供未核验提示保留。
+4. 已授权资料库查询 `platform=jd AND brand=ksf AND category=banmian`:同代际商品96、analysis评价18,商品详情评论只能属于所选商品;跨平台/跨品牌不得混入。商品缺评论应如实显示,不能拼接其它商品评价。验证评论数/价格排序、政策正文和条款,移除的日期筛选不再出现。
+5. 分层回报:包核验、静态计划/应用、函数部署/回读、数据库位置/权限/代际、服务托管和同源授权、公开API、生产浏览器。附具体requestId/真实HTTP状态/备份回执;未开生产浏览器则 **browserVerified=false**。资料库若卡在授权入口,其他页面的完成状态照实分别汇报。
+
+## 7. 回退
+
+静态:按备份journal逐文件核当前SHA仍等于本次after;只有符合才恢复before,本次新增文件在仍为本包内容时才撤回。首页始终保持,已被后续发布修改的文件停止回退并报告,不全站复制旧目录。
+
+函数:只撤回本次新增、且仍与本包SHA相同的两个Function记录,保留旧函数;不删除已保存的分组配置/审计,不动员工昵称或报告。
+
+服务/路由:停止本次新增服务,恢复备份的精确代理差异,校验后reload;不改Agent网关。私有数据库保持或在确认仅为本次新建且SHA未变后隔离,不删已有库/registry。完整保留回执与备份。

+ 75 - 0
docs/board-deploy-20261106/完整发布执行单.md

@@ -0,0 +1,75 @@
+# 运维001:全部已推送与补齐源码,统一部署
+
+本单取代“只处理数据库分包”的理解,覆盖此前看板改动和本次工作区补齐。用户已明确要求全部部署,无须再询问是否发布。包内RELEASE.json的sourceCommit是本次唯一发布源码版本;不要仅挑最后一条提交,也不要依次覆盖多个旧包。
+
+## 累计范围
+
+| 部分 | 来源 | 本轮动作 |
+|---|---|---|
+| 工作坊数据与项目进度 | d14d02f | 保留线上身份叠加,核真实公开数据链路 |
+| 全页布局、中心详情、路由、报告分页、证据台账 | bb38e37、0ba3c0a | 按完整静态白名单发布 |
+| 成员分组管理弹窗 | bb38e37 | 发布页面,补齐两个分组函数,验增删改与归属保持 |
+| 行业资料库与紧凑商品详情、评论数/价格排序 | bb38e37、0ba3c0a | 发布页面、私有只读服务及同源授权入口 |
+| 精确查询合同、AND条件、来源与generation校验 | 本次补齐查询源码与测试 | 服务部署使用本次代码,不继续用旧r1中的旧查询源码 |
+| 两份Skill、安装/校验/分包工具及维护源码 | 本次纳入Git | 已安装同指纹则验保持;缺项按既有安装器补齐 |
+| 研究数据库本体 | 两个数据库分包,独立交付 | 确认实际收到→合并→安装→回读,不把说明当文件已到 |
+
+本包不含数据库本体和任何凭据。代码包、两个数据库分包是三个不同附件,**都要实际发送**。代码已推送不代表文件已部署;王刚容器可查询也不代表看板宿主已接库。
+
+## 1. 包与现场检查
+
+核外部delivery-receipt.json和包内FILES.sha256;读取RELEASE.json、deploy/complete-release-plan.json。源码提交应包含此前三个提交(已核祖先关系),覆盖全部累计功能。解包到站点外,例如 `/opt/ksf-complete-deploy-20261106-r1`。回执和备份另放私有目录。
+
+静态发布器只处理71个src白名单项,保留首页/vendor/现有姓名修复,不删除旧站点额外文件。后端服务、Skill、资料、管理脚本、说明不进Web目录。维护源码入Git供复核和复现;包内没有自动执行历史采集/入库任务的入口,不重跑旧工作坊。
+
+## 2. 数据库投递和安装,两处实际路径分别验
+
+先确认两个附件part01/part02都收到。按包内《数据库分包投递.md》合并为原ZIP;完整ZIP应25,913,256字节、SHA `1ba1ac2fa6a8b774f8945d88e014c07dd5e03332792af298a41f015ba6946f9f`。不得继续用118,064字节的规则包替代数据库包。
+
+王刚001目标仍是 `agent-node-u17879018108:/opt/data/ksf-voc-library/library.sqlite`。先--check、再--apply一次、再按原8步回读;不重装已有新Skill。安装后提供**该容器**stat字节数/权限/属主、库SHA、contract实际generation及9例和来源回读回执。
+
+生产看板的只读服务须能读实际宿主的私有库或核实共享挂载。库98,918,400字节、SHA `944e4bb3a5d65dd897ea7c912e1af001aa845db1ba1f968a7b0ee653dc60fa50`、代际 `7e0df42395d9d2ea39dd5b537d17ae35ac910cc29721ed92a680b7fe6b25c92c`。两个机器上的同名路径不当共享存储;与王刚同SHA/代际才说本次快照对齐,未来自动更新未配置。
+
+## 3. 后端与当前Skill
+
+- 使用本包 `payload/server/library-api.mjs` 与完整 `payload/server/voc-library/` 部署私有只读服务。本次retrieval.py和voc_db.py已包含新版规则合同和generation guard,无数据库迁移。仅绑定本机,按实际服务用户赋私有库读取权限,生产进程配置沿用细则中的5项环境变量。
+- 补齐同源POST `/api/library`:复用已核实的访问者授权机制,校验后在服务端代理携带服务token;token不进前端。若尚无可复用授权入口,明确单列该阻塞,不绕过鉴权;其他已具备条件的组件继续发布。
+- 只创建/核验 `ksfGetPublicMemberGroups`、`ksfManagePublicMemberGroups` 两函数,成品与manifest在 `payload/server/dist/member-groups/`。存在同SHA则no-op,异版或重复记录报告;保留既有六个工作坊/身份函数与其热修,不能跑全函数旧构建器覆盖它们。
+- `payload/skills/` 是Git中当前两份准确Skill源码:研究9/9、manifest `4da410bb882dc73e277c740d13407918d9c9d5da2196ac9211f6ff7c009692fb`;工作坊10/10、manifest `d66e50547f04d761265dec529106d0efbd2c1451001d02857af866f29f5f4fe4`。按已报已安装状态先回读保持,不重写credential-binding、员工档案、会话索引。不用本包直接覆盖未知Skill基线;缺项仍走已交付的查询规则升级包及其--check/--apply。
+- 平台桥v4和JSON文本返回热修已部署,本次只核保持。其他维护脚本纳入版本库供开发追踪,不因此替换运行中的桥或重启数字人。
+
+更详细的权限、进程、函数发布、分组测试、数据回退步骤见包内《看板部署细则.md》,其中静态工具路径以本单为准。
+
+## 4. 发布全部静态变更
+
+先核真实站点路径,再在仅此静态步骤使用可供Web读取的umask:
+
+```sh
+node /opt/ksf-complete-deploy-20261106-r1/scripts/publish-board.mjs --target-dir /opt/kangshifu-web
+(umask 022; node /opt/ksf-complete-deploy-20261106-r1/scripts/publish-board.mjs --target-dir /opt/kangshifu-web --apply)
+```
+
+第一条只读。现网未另有发布时,71项预计35改变/10新增。若上一包已经部分或全部发布,同内容应no-op,按现场真实changes回报,不能强行写满35项。未知现场版本或首页变化则停止该步骤并给具体文件,不改基线绕过。
+
+生产首页保持 `dfeb6a6d0b63e8e1190f9308bf6619a5ca523a5a2f91af5f7ca5ad5fc9642db3`。发布器先备份全部旧文件、再白名单写入、逐一回读;异常自动尝试回退,保留并发版本。外网再核JS SHA/Content-Type,不能把SPA返回首页的200算模块成功。
+
+## 5. 按组件验收全部功能
+
+1. 工作台、卡池、五阶段、全部报告页面、证据台账、成员、行业资料库逐页打开。机会卡→关联成果/报告居中打开;全报告滚动、关闭、刷新、前进后退和复制深链接恢复正常。旧演示路由回工作台,原首页不改。
+2. 成员按钮打开管理弹窗;在临时测试组验证增组、改名、添加未分组成员、移除、删组,完成后还原。版本冲突不会覆盖,成员稳定ID和成果归属、本人昵称与未核验标记保持。
+3. 已授权行业资料库读实际库;严格京东×康师傅×拌面商品96、analysis评价18,以本次同代际回读核对。商品详情内部评论/价格/规格固定商品ID;有来源,跨平台/品牌不混。评论数量、价格只排序,无已移除日期区间,政策/条款能打开。
+4. 两份Skill指纹与规则版本保持;王刚真实企微消息完成contract→查询→统计→get/source,不把CLI成功当真实消息验收。
+5. 桌面1440/1024、手机390/320复验无遮挡、横向溢出、侧边栏残留或控制台异常。必须实际打开生产浏览器才写browserVerified=true。
+
+按下表逐行给出 **通过/失败/待验** 与证据,不合并为一个“部署成功”:
+
+| 项目 | 必须回报 |
+|---|---|
+| 发布版本与文件 | sourceCommit、白名单计划/实际changes、备份、外网SHA |
+| 成员分组 | 两Function编号/SHA、查询回执、临时组写验收与还原 |
+| 数据库 | 两包接收/合并、目标容器与看板服务各自实际路径、权限、SHA/代际 |
+| 查询与授权服务 | 进程托管、只读回执、同源授权成功、未授权拒绝 |
+| 既有能力保持 | 两Skill清单、昵称/身份/会话/桥热修、六函数指纹 |
+| 生产浏览器 | 逐页/路由/桌面手机结果、browserVerified |
+
+有阻塞时写具体组件、错误码、缺项;其余组件继续完成,不再只回复数据库未到就结束整批部署。回退按细则保留外部备份,不删业务记录、称呼或分组注册表,不覆盖后续并发改动。

+ 35 - 0
docs/first-live-S1-20261102.md

@@ -0,0 +1,35 @@
+# S1真实资料首批小结:统一拌面官网条目与待补问题
+
+已实际取得并入库统一官网2页,整理9个口味条目、9条关联引文和1个研究工作分类;9条引文实际来自2段官网口味/包装列表,来源厂商只有1家。
+
+资料仅支持记录官网列出的口味和系列包装;不能当作9个独立SKU、当前在售清单、销量或消费者需求证据。
+
+已完成:官网采集、原件归档、查重入库及独立回读。尚缺:康师傅同口径产品、SKU与价格、消费者评价,以及后续供应链与技术依据。
+
+|口味条目|官网系列包装|
+|---|---|
+|统一酱拌面·老干妈豆豉辣酱风味|碗、五合一(袋*5)|
+|统一酱拌面·卤香牛肉风味|碗、五合一(袋*5)|
+|统一酱拌面·老坛酸菜牛肉风味|碗、五合一(袋*5)|
+|统一酱拌面·XO酱海鲜风味|碗、五合一(袋*5)|
+|统一巧面馆拌面·老坛酸菜酱拌面|碗、袋、五合一(袋*5)|
+|统一巧面馆拌面·老成都担担面|碗、袋、五合一(袋*5)|
+|统一巧面馆拌面·重庆豌豆杂酱风味面|碗、袋、五合一(袋*5)|
+|统一巧面馆拌面·串串香干碟拌面|碗、袋、五合一(袋*5)|
+|统一巧面馆拌面·贵州豆豉辣酱风味面|碗、袋、五合一(袋*5)|
+
+## 待补问题
+
+1. 优先纳入比较的康师傅拌面系列、具体产品名或官方资料入口有哪些?先补自有产品,建立相同粒度的对照。
+2. 各口味目前是否在售?请以有日期的官方商品页或销售页面核对;官网展示时间不能代替上市时间。
+3. 每个口味对应哪些包装和净含量?需逐SKU核实,并保留配料表、营养标签原件,不把系列包装直接分配给所有口味。
+4. 先观察哪些渠道、地区和店铺?每条价格需同时记录日期、包装数量、优惠条件及标价/实付口径,才能比较单份价格。
+5. 消费者对口味、面体、酱量、沥水/拌制、分量与价格具体说了什么?需保存真实逐字评价和来源,并保留正负及无明显倾向内容;这些维度目前只是采集问题。
+6. 后续如进入新品方案,哪些配方比例、原料规格、工艺参数、成本和法规资料可由企业提供?资料未到前保持未验证,不推导量产可行。
+
+来源:
+
+- https://www.uni-president.com.cn/products-jiangbanmian.asp(已入库:BgUB6dnxN1)
+- https://www.uni-president.com.cn/products-qiaomianguan.asp(已入库:SFiKA0D7n9)
+
+2026年11月2日15:32更新:本小结已由王刚001保存,sessionId=btrFqkz0yw、artifactId=cOm6c8gg7z。本机已按编号独立回读并核对来源、正文、两段引文、六条待补问题及真实会话归属;匿名浏览器电脑端与390px检查通过。公开地址:https://kangshifu.fmode.cn/report/cOm6c8gg7z 。当前是live课堂过程成果,未经员工正式确认,未正式发布。来源CI8dFPUdTz是本次小结生成的正文来源,不是第三个官网页面;原始官网来源仍为BgUB6dnxN1和SFiKA0D7n9。

+ 48 - 0
docs/first-live-sources-20261102.md

@@ -0,0 +1,48 @@
+# 第一批真实官网资料采集汇总
+
+任务键:KSF-真实资料-20261102-01
+
+本批由Codex通过HTTPS读取并归档,已由王刚001导入目标业务库,2026年11月2日15:15本机独立只读核验通过。只包含公开官网产品自述,不代表消费者研究、当前在售或产品性能独立验证。
+
+## 已取得资料
+
+| 产品口味条目 | 官网包装说明(系列级) | 来源 |
+|---|---|---|
+|统一酱拌面·老干妈豆豉辣酱风味|碗、五合一(袋*5)|https://www.uni-president.com.cn/products-jiangbanmian.asp|
+|统一酱拌面·卤香牛肉风味|碗、五合一(袋*5)|https://www.uni-president.com.cn/products-jiangbanmian.asp|
+|统一酱拌面·老坛酸菜牛肉风味|碗、五合一(袋*5)|https://www.uni-president.com.cn/products-jiangbanmian.asp|
+|统一酱拌面·XO酱海鲜风味|碗、五合一(袋*5)|https://www.uni-president.com.cn/products-jiangbanmian.asp|
+|统一巧面馆拌面·老坛酸菜酱拌面|碗、袋、五合一(袋*5)|https://www.uni-president.com.cn/products-qiaomianguan.asp|
+|统一巧面馆拌面·老成都担担面|碗、袋、五合一(袋*5)|https://www.uni-president.com.cn/products-qiaomianguan.asp|
+|统一巧面馆拌面·重庆豌豆杂酱风味面|碗、袋、五合一(袋*5)|https://www.uni-president.com.cn/products-qiaomianguan.asp|
+|统一巧面馆拌面·串串香干碟拌面|碗、袋、五合一(袋*5)|https://www.uni-president.com.cn/products-qiaomianguan.asp|
+|统一巧面馆拌面·贵州豆豉辣酱风味面|碗、袋、五合一(袋*5)|https://www.uni-president.com.cn/products-qiaomianguan.asp|
+
+本批9个口味条目来自统一同一厂商的2个系列页,不是9个独立来源,也不是完整竞品样本。每个口味关联一段口味/包装列表引文;包装为系列级,不假设每种口味均有所有包装。未填写价格、净含量、配方比例、销量或消费痛点。
+
+## 来源归档
+
+- https://www.uni-president.com.cn/products-jiangbanmian.asp
+- 取得时间:2026-11-02T07:02:15.923Z
+- 字符集:gb18030,严格解码;未知发布日期
+- 原始响应SHA-256:c4f16115543499d97b258d4c85bdb0a7506ce049efbc6908844c31c1ba8011d6
+- 提取全文SHA-256:6640ad363793906e7df6ba18e73538edbed4b8a35d8b38bca626c2d42862aec2
+
+- https://www.uni-president.com.cn/products-qiaomianguan.asp
+- 取得时间:2026-11-02T07:02:19.238Z
+- 字符集:gb18030,严格解码;未知发布日期
+- 原始响应SHA-256:0954b27a0d357e86d7cabb8a1741354ff162cfe668057c1b0ae1ac811ace59e9
+- 提取全文SHA-256:8bf7d5207320f39afe9e02ccab8b466a126ee859bbeeaa08899bc21a320f5fe2
+
+## 下一步需要补充的细节
+
+1. 康师傅自有拌面同口径产品资料尚待补齐,暂不能作完整自有/竞品比较
+2. 官网页面无明确发布日期,不能据此判断新品上市时间或当前在售状态
+3. 缺单个SKU净含量、配料表、营养信息及每个口味对应包装组合
+4. 缺渠道/店铺、实际成交或观察价格、优惠口径与价格观察时间
+5. 缺真实消费者逐字评价、使用场景与需求强度,不从品牌文案推断用户痛点
+6. 缺供应商报价、配方比例、工艺参数、成本与法规验证,不能推出量产结论
+
+建议先完成此批入库及独立回读,再补康师傅同口径产品、包装规格/渠道价格和逐字消费者评价,保存真实S1课堂小结。不要把sample K会话改成live,不根据官网营销句生成需求已验证结论。
+
+本地工作坊测试77/77通过;目标实例导入及独立回读已通过。两份来源BgUB6dnxN1/SFiKA0D7n9,共9条产品口味、9条证据和1个分类Mc9FJpryn4。S1小结已准备,保存到看板与页面验收尚待执行。独立核验记录见work/first-live-readback-20261102.json。

+ 110 - 0
docs/innovation-dashboard-plan-20261103/01-需求理解与五端方案.md

@@ -0,0 +1,110 @@
+# 康师傅产品创新工作坊:需求理解与五端看板方案
+
+2026-11-03 · 方案建议版 v1
+
+本轮交付资料分析、产品方案、模拟数据设计和王刚001执行交接。PC效果图由王刚001核实其本机的 skill-image 后生成,先展示效果图,再制作可交互 Demo 和五个系统介绍长页。本轮尚未生成效果图、开发 Demo 或发布页面。
+
+## 1. 这次要呈现什么
+
+让领导看见全员创新工作的进展,让产品经理能沿着同一条产品线持续工作,让教练知道每组卡在哪里。一个想法需要能追到采集任务、用户原话、竞品观察、机会卡、验证问题和最终产品概念。
+
+主流程为:创意提出 → 明确方向与假设 → 搜集已有材料及定向补证 → 形成机会卡 → 选题建立课堂项目 → 验证与取舍 → 场景化买点与产品概念 → 路演及后续行动。
+
+三条重点:
+
+- **过程可追溯**:每个结论都能展开证据、出处、适用范围和缺口,不能只显示一张报告封面。
+- **多人多产品并行**:一人参与多个产品,同组同时推进不同方向,成员可跨组评审;共享资料不重复采集,产品判断分别留存。
+- **课堂进展与研究结果分开**:完成作业、完成课堂项目、取得实物验证结果、企业正式立项分别显示。
+
+这次是在已跑通的工作坊链路上规划面向多人使用的展示与协作层。五端共享同一套对象、编号和事件,不做五套互不相通的数据系统。
+
+## 2. 基础材料实际提供了什么
+
+| 材料 | 实际内容 | 本方案如何使用 |
+|---|---|---|
+| 线下追问清单 | 创新目标、产品想象、口味、技术、人群场景、历史试错、判断依据,以及销售/BOM/SKU/调研/投诉等补料问题 | 转成创意表单、研究任务和待补问题字段。清单是问题,不是已经取得的访谈结论 |
+| 调研问卷 HTML | 基本信息、AI熟练度、课题期待、竞品、人群、目标价格带、可提供资料等空白选项 | 设计学员画像及分组筛选。未取得答卷,不能计算真实AI使用率或偏好比例 |
+| 数据整理 ZIP | 六场景用户笔记260行、竞品笔记120行、商品400行、工艺/标准/原料等49行,含采集原始响应与统计说明 | 作为资料入口与场景设计依据;保留包内口径,不把行数当消费者人数或市场份额 |
+| 项目提案 PPTX | 产品经理五类瓶颈,三个半天课程、夜间采集、机会卡迭代、路演与49天跟进 | 作为康师傅具体课表与作业安排的主要依据,重点见第2—8、11页 |
+| avatar-workshop 网页 | 通用SI训练营20步,强调对象/关系/规则/SOP、看板列表/筛选/统计/详情、模拟数据标记及工程规范 | 作为系统化建设与验收参考。它不是本项目三个半天的具体课表 |
+
+已核对的资料数字见 `data/source-inventory.json`。来源真实性和页面现状未在本轮重新联网逐条核验,不能把“用户已提供归档”改写成“本轮已验证当前市场”。
+
+### 必须保留的数据口径
+
+- 380是社媒CSV行数。按平台、作者、标题做候选对照有372组,不是按原平台ID完成去重,也不是372个真实购买者。
+- 400是商品页面记录,链接400个;可能有不同组合装、相近产品和跨平台重复,不直接称400个独立当前SKU。
+- 仅125条有包内可用的百克价估算值,另外275条不进该口径统计。估算价不是实际成交价,不能展示GMV、销量和市占率。
+- “辣”字命中不必然表示“太辣”;“健康”标签不证明营养健康;高点赞不等于需求强度。
+- 工艺表实际49行。说明正文末尾仍写47条,展示以实际行数为准;其中标准状态、工艺参数和旧询价需另行核实,不等于康师傅已有产线能力。
+- 追问清单里的旧快照“112条/39个SKU/0个非油炸”等,与本包范围不同。本包康师傅商品行53条、其中2条标题工艺标签为非油炸,不直接据此判定产品现状或趋势变化。
+
+## 3. 重点需求及其来源
+
+| 需求 | 对应材料或业务痛点 | 页面必须支持的动作 |
+|---|---|---|
+| 明确创新目的 | 追问清单:抢份额、防竞品、高端化、新人群或新场景 | 记录产品任务、目标人群、使用情境、候选价格及依据,标明经验判断或资料支持 |
+| 统一情报入口 | 提案:资料分散,人工收集繁琐 | 搜索、筛选、阅读原文、质量标记、跨项目复用,查看采集任务和失败原因 |
+| 从用户声音形成机会 | 提案:缺系统推演与卖点抓手 | 人群/场景/JTBD/痛点聚类,区分AI建议和人工复核,三源证据与缺口同时入卡 |
+| 判断能否继续投入 | 提案及追问:验证慢,工艺/成本边界不清 | 桌面初评、反证、工艺约束、成本假设、待补资料,以及继续/调整/暂停理由 |
+| 设计产品与买点 | 提案:痛点与卖点对应不清 | 将痛点、产品特征、包装提示、用户买点和证据逐项对应,保留方案版本 |
+| 多人共同产出 | 用户本次要求、提案的分组共创 | 成员/小组/产品关联、负责人、分工、评论、版本差异和冲突提示 |
+| 管课堂、看成果 | 三个半天、夜间任务、路演及49天跟进 | 每组作业提交、质量检查、卡点、教练反馈、路演顺序及后续行动 |
+
+## 4. 五端划分
+
+“端”指工作视图,不强制拆成五个站点。建议一个公开入口,统一导航和底座,按使用者切换。
+
+| 工作视图 | 主要使用者 | 第一屏回答的问题 | 主要内容与操作 |
+|---|---|---|---|
+| **A 创新驾驶舱** | 刘总、项目负责人、管理者 | 有哪些方向、谁在推进、哪里卡住、需要什么支持? | 创意到项目漏斗、项目阶段分布、人群×场景机会矩阵、重点产品清单、风险与资源请求;可点进具体卡和报告 |
+| **B 情报与用户洞察中心** | 产品经理、调研人员、采集Agent | 手里有什么资料,哪些能支持当前判断,还缺什么? | 资料总览、采集任务、用户原话、竞品/规格/价格观察、痛点簇、出处抽屉、复用引用和质量标记 |
+| **C 产品共创工作台** | 学员、小组负责人、产品经理 | 我参与哪些产品,当前该做什么,怎样交作业? | 我的任务/小组产品、创意便笺、S1—S5进度、机会卡、协作分工、方案版本、待补问题及Agent任务轨迹 |
+| **D 验证与评审中心** | 教练、研发、工艺、供应链、评审者 | 机会依据是否充分,继续、修改还是暂停? | 证据/反证核验、模型维度与缺项、可行性约束、成本假设、验证任务、修改意见及概念评审 |
+| **E 课堂运营与成果中心** | 主讲、助教、项目运营 | 三个半天每组交了什么,哪里需要辅导? | 课表与任务卡、6组作业矩阵、夜间采集验收、卡点队列、点评、成果墙、路演、49天行动清单 |
+
+```mermaid
+flowchart LR
+  B[情报与用户洞察中心] --> C[产品共创工作台]
+  C --> D[验证与评审中心]
+  D -->|补证或修改| C
+  C --> A[创新驾驶舱]
+  D --> A
+  E[课堂运营与成果中心] -->|任务与反馈| C
+  C -->|作业与版本| E
+```
+
+公开看板保持无需登录。“姓名/角色切换”用于演示筛选,不代表已认证身份。真实员工归属继续沿用已有可信消息登记;真实企业资料和写入权限不能由界面下拉框决定。
+
+## 5. 三个半天的作业与页面映射
+
+具体钟点在提案中有少量不一致(如第二天下午14:00或13:30开始);本版以三个半天及产出关系设计,不擅自确定最终开课时间。
+
+| 时段 | 人的主要工作 | Agent辅助工作 | 应提交的作业 | 主要页面 |
+|---|---|---|---|---|
+| 第一半天:D1下午 | 各组明确课题,建立品类/竞品图谱,提出痛点假设,确定验证纪律 | 检索已归档资料,整理品类/规格/价格口径,生成有出处的资料摘要 | S1报告、竞品矩阵、假设清单、验证配置卡、夜间研究任务卡 | B情报中心、C共创台、E课堂运营 |
+| D1夜间:课外任务 | 确认采集范围和缺口;不把“夜间排程”当已采集 | 按任务检索/采集/查重,保存原文和失败状态,输出预聚类 | 原始资料索引、采集结果、VOC预聚类、缺口清单 | B任务队列、E夜间验收 |
+| 第二半天:D2上午 | 验收夜间资料,复核人群/场景/JTBD/痛点,筛选机会 | 辅助聚类、检索相反证据,按赛道×用户×场景拟机会卡 | S2洞察报告、候选机会卡v1、选题依据、项目S3基线 | B洞察、C机会卡/项目、A驾驶舱 |
+| 第三半天:D2下午 | 做桌面验证,明确工艺/成本/合规缺项,设计买点、概念与路演 | 展开证据与反证、辅助初评,整理版本差异和路演材料 | S4评估、机会卡v2、S5概念与报告、机会卡v3/路演稿、49天行动 | D评审、C方案、E成果墙、A驾驶舱 |
+
+没有真实KANO问卷答卷时只显示“需求分档建议”;没有实物、BOM或企业产线资料时交“桌面评估及验证条件”。缺项必须可见,但不因此把整个课堂无限卡在补料阶段。
+
+## 6. 多人、多产品的演示设定
+
+建议36名模拟学员、6组、每组2个产品项目,共12个方向。此规模是为了展示协作,不是实际参训名单或正式分组。
+
+- 一名成员可负责一个项目,也可给其他项目提供调研或工艺意见;每个项目有明确负责人。
+- 同一条来源可被多卡多项目引用,引用关系分别记录;资料不因被引用而复制成新的消费者。
+- 创意、机会卡、项目、方案是不同对象:12个项目不等于全部24张机会卡都立项。
+- 工作坊/小组/产品/成员/阶段/资料模式是全局筛选维度,切换视图后仍指向同一产品和版本。
+- 演示中至少保留推进顺利、需要补证、工艺卡住、主动否决四种故事,避免全绿。
+- completed表示课堂流程结束,产品仍可保留待验证问题。企业审批与正式发布另有状态。
+
+## 7. 首版交付顺序
+
+1. 王刚001读取本方案、原件和其本机 skill-image 指引,先出五端各一张PC首页效果图,逐张发送预览。
+2. 按本包12个产品故事线和数据口径制作独立Demo;各端至少有概览、筛选列表、统计与详情。
+3. 制作一个总介绍页和五个系统介绍长页,分别解释适用者、主流程、样例和协作关系。
+4. 用真实浏览器演示一个顺利路径和两个受阻路径,回报实际路由、文件/仓库、预览图及已完成范围。
+
+具体出图提示见03;长页内容见04;数据规模、种子与计算约束见02;技术与验收见05。当前生产课堂已完成,新的模拟演示不覆盖其数据和历史回执。

+ 93 - 0
docs/innovation-dashboard-plan-20261103/02-模拟数据与产品故事线.md

@@ -0,0 +1,93 @@
+# 模拟数据与产品故事线
+
+2026-11-03 · 设计稿,不是康师傅真实经营数据
+
+## 1. 三层资料分开显示
+
+| 层 | 标记 | 可以表达什么 |
+|---|---|---|
+| 用户提供的归档 | 用户提供资料 / 归档待复核 | 包内确实有这些行和这些文本,保留文件路径、行号、原平台ID/链接可用性;不默认等于已独立核验 |
+| 从资料归纳的假设 | 待验证假设 | 人群/场景/需求的拟议关系、产品方向、待补条件;不能写成已证实需求 |
+| 为演示生成的数据 | 模拟数据,dataMode=sample | 虚构人物、评论、价格、成本、任务和结果,用于展示页面和流程;不能与真实统计合并 |
+
+“大量真实数据”在本轮实现为“有行业细节、关系完整的真实感模拟数据”。不复制真实用户姓名后伪造发言,不把归档原话改几个字再当新的独立用户。
+
+## 2. 建议完整Demo规模
+
+下面是后续生成目标,不是本交接包已经含有的全部记录数。`data/preview-seed.json`仅提供关联完整的种子,`scaleTargets`与`actualSeedCounts`分别记录。
+
+| 对象 | 建议数量 | 分配及约束 |
+|---|---:|---|
+| 模拟学员 | 36 | 六组各6人,均有明确模拟标记,不填真实手机号 |
+| 小组 | 6 | 与资料包六个人群方向对应 |
+| 创意 | 72 | 每组12,包含相似想法合并、待补、已采纳和放弃 |
+| 研究/采集任务 | 180 | 每组30,含共享资料复用、成功、排队、失败和待补来源 |
+| 模拟用户声音 | 12,000 | 六场景各2,000,具不同使用目的、褒贬、制备条件、替代品、预算和行为原因 |
+| 模拟商品观察 | 720 | 商品系列/规格/包装/组合装/渠道分开;不等于720个已经存在的真实商品 |
+| 模拟价格快照 | 2,880 | 每商品4条,不同日期/渠道/组合/活动口径明确;无可比价时留空 |
+| 结构化证据 | 360 | 从模拟来源截取逐字片段,保留sourceId、区间、用途和反证,不能凭空配引文 |
+| 机会卡 | 24 | 每组4:2张选入课堂项目、1张候选、1张否决 |
+| 课堂项目 | 12 | 每组2;当前快照S3=2、S4=6、S5=4 |
+| 验证任务 | 30 | 仅10个已进入S4或S5的项目各3条 |
+| 课堂方案版本 | 8 | 仅4个S5项目各v1/v2,S3/S4创意草案不冒充已保存方案 |
+| 阶段报告 | 44 | 组级S1/S2/S3候选各6;项目S3基线12、S4评估10、S5报告4 |
+| 待补问题 | 72 | 每项目6条,带负责人、问题状态、补证关系和处理结论 |
+| 协作/评审记录 | 312 | 包含质疑、答复、补证、版本差异与取舍说明,不全是赞同 |
+
+暂不生成虚构销售额、市场份额、企业毛利率或“已投产销量”。这些业务数据未由本包提供;需要展示成本时用“演示假设”单独标明。
+
+## 3. 十二个产品方向
+
+全部为课堂模拟假设。名称中的“高蛋白/高纤”等是候选设计方向,不是经检测证明的营养声称。以下价格为每份目标零售价演示假设,与原始资料的元/100g分开。
+
+| ID/组 | 产品方向 | 人群与具体使用场景 | 目标价假设 | 关键矛盾/反证 | 当前课堂状态 |
+|---|---|---|---:|---|---|
+| P01/G1 | 工位轻香葱油拌面 | 工位午餐,只有热水,希望不占公用微波炉 | 6.9元/份 | 低气味与酱香可能冲突,制备和沥水条件待核 | S5/课堂完成 |
+| P02/G1 | 晚归番茄鸡蛋汤面 | 晚归加班餐,希望热乎但不油腻 | 8.9元/份 | 泡发口感与加料习惯混杂,不能把加蛋都归为不够吃 | S4/进行中 |
+| P03/G2 | 宿舍轻辣番茄拌面 | 无灶具宿舍,预算敏感,想吃辣但可调 | 4.9元/份 | 目标制备方式与现有面体条件冲突,需核热水复水路线 | S4/卡住 |
+| P04/G2 | 夜宵芝士轻辣拌面 | 同寝分享夜宵,重视风味,可接受少量配料 | 5.9元/份 | 芝士浓郁与轻负担诉求冲突,配料不是饱腹证据 | S5/进行中 |
+| P05/G3 | 露营菌菇热汤面 | 有限炊具的露营,想喝热汤并减少清洗 | 9.9元/份 | 热源、用水、垃圾回收和运输条件不统一 | S4/卡住 |
+| P06/G3 | 高铁低气味葱香拌面 | 出差高铁,只有开水、空间狭小 | 7.9元/份 | 弃水、气味与携带便利相互牵制,不能只强调便携 | S3/进行中 |
+| P07/G4 | 高纤荞麦拌面 | 午间控卡人群,想吃面且重视口感 | 8.9元/份 | 配方、营养检测、口感和成本均需证据 | S4/进行中 |
+| P08/G4 | 高蛋白番茄拌面 | 运动后便餐,关注配料与饱腹体验 | 9.9元/份 | 蛋白来源、检测与热量口径未知,不做健康结论 | S4/进行中 |
+| P09/G5 | 家庭双人小份组合 | 两人晚餐,偏好分装、可自行搭菜 | 6.45元/份;12.9元/两份 | 总价与单份价混淆,包装成本和真实用餐量待核 | S5/课堂完成 |
+| P10/G5 | 一人食酱香大满足 | 一人晚餐,想减少临时再补主食 | 8.9元/份 | 当正餐、加餐和习惯搭配尚未区分,份量判断缺证 | S3/卡住 |
+| P11/G6 | 贵州红酸汤风味面 | 地域尝鲜者,想体验酸鲜但怕踩雷 | 9.9元/份 | 风味偏好分歧与“正宗”表达依据待核 | S5/进行中 |
+| P12/G6 | 川香椒麻宽面 | 嗜辣尝鲜者,期待可控椒麻层次 | 8.9元/份 | 宽面复水、麻辣分离、酱料挂壁及批次一致性待核 | S4/进行中 |
+
+该快照汇总:项目12,进行中7、卡住3、课堂完成2;S3为2、S4为6、S5为4。五张首页图和后续Demo必须使用同一份汇总。
+
+## 4. 用户声音怎样生成得有细节
+
+生成轴至少包括:场景6类、用餐目的(正餐/加餐/夜宵/分享)、制备资源(开水/微波炉/电煮锅/户外热源)、预算、气味敏感度、口感偏好、辣度耐受、习惯性配菜、吃后补主食原因、购买与食用是否可核、评价情绪及帖子性质。
+
+必须包含相反情况:觉得一份刚好/吃不完、愿意煮制换口感、习惯加蛋、对非油炸无偏好、喜欢重辣、不追求地域正宗等。各类占比是演示生成规则,不是对真实人群的估计。广告、无关内容和内容不全也应有单独状态。
+
+六条种子原话均为人工构造的模拟文本:
+
+1. 工位:“中午只有热水,排队等微波炉太久。我会先看要不要煮,酱味太冲就留到下班再吃。”
+2. 宿舍:“想吃一点辣,但别辣到喝好多水。宿舍不能用锅,写清楚能不能泡比送几个料包更重要。”
+3. 户外:“露营时面是好带,可用水和收垃圾更麻烦。有炉子和没炉子,我选的东西完全不一样。”
+4. 控卡:“我会看每份热量和配料,但不想只吃荞麦味。加鸡蛋是我平时的习惯,不一定是面太少。”
+5. 家庭:“两个人口味不一样,分开酱包会方便。整包便宜不代表每份便宜,我会看一包到底算几顿。”
+6. 尝鲜:“红酸汤第一次觉得新鲜,吃第二次更在意酸味和面条搭不搭,不是名字越地方就越想买。”
+
+## 5. 完整关系与计算规则
+
+- 所有模拟实体带 `demoId / dataMode=sample / isSynthetic=true`,标题/页面亦标“模拟数据”。基础资料用独立的 `provided_archive`来源标识,不参与模拟样本汇总。
+- Member与Project为多对多;Project有一个负责人,Group是默认归属;共享研究者可以跨项目协作。
+- 机会卡关联原始创意、来源引用和假设;被选中的12张卡分别建立项目,其余候选/否决不建项目。
+- Evidence.quote必须在Source.rawText里逐字存在。每次引用都说明支持/反对什么,不能“引用存在”就推断结论成立。
+- 方案属于项目且连续版本化;历史报告保存当时状态,不因当前项目完成而改写。
+- 评分显示来源、维度和模型版本。缺工艺/成本资料时维度为null并显示缺项,不用0凑总分。无完整依据时仅显示已知维度,不给正式综合结论。
+- KANO只在有对应问卷及计分依据时称结果,否则显示“AI分档建议”。点赞、标签命中和搜索热度分别统计。
+- 页头KPI、图表、列表与详情从同一数据集计算。筛选后显示当前分母;不能给每张效果图编一套独立数字。
+- 演示自动化事件、任务百分比和验证结果均标“模拟运行”;真实模型调用无usage时显示未提供,不伪造token节省数据。
+
+## 6. 演示时必须走的三条故事线
+
+1. **推进路径P01**:创意 → 查已有资料 → 逐字证据 → 机会卡 → 项目S3基线 → S4桌面评估 → S5方案v2与报告 → 课堂完成,同时仍可见未决问题。
+2. **工艺受阻P03**:宿舍不开火要求 → 搜到需煮版本 → 评审提出矛盾 → 标记卡住 → 建待补工艺任务;领导页和教练页同步看到同一问题。
+3. **证据不足P10**:加蛋/加菜 → 研究者尝试归因份量不足 → 展开反证“习惯搭配/非正餐” → 保留候选判断,补用餐目的与实际行为问题,不强行给高分。
+
+本包的种子数据不等于上述12,000条完整数据已生成。王刚001先用其生成预览内容,开发Demo时再按生成规则扩展,并实际校验数量与关联关系。

+ 57 - 0
docs/innovation-dashboard-plan-20261103/03-PC效果图出图说明.md

@@ -0,0 +1,57 @@
+# 五端PC效果图:交王刚001使用 skill-image
+
+本文件是后续出图设计说明,本轮尚未出图。先核实本机 skill-image 的实际位置、版本、入口、可用尺寸与鉴权方式,读取其指引后调用;不要照抄课程示例命令猜测本机工具名或模型名。图片生成失败须如实说明。
+
+## 1. 统一视觉与交付规格
+
+- 五张完整PC系统首页图,目标1920×1080或工具支持的相近横屏比例。生成尺寸、模型与耗时按实际回执填写。
+- 康师傅产品创新工作坊,暖白底、细灰线、深墨色正文,品牌红做主强调;研究蓝、协作紫、验证青、课堂琥珀作辅助,不用五套互不相干的视觉语言。
+- 左侧统一导航,顶部显示工作坊、当前时段、搜索、模拟数据标记;主区有信息层级、紧凑表格、状态标签、局部图表和可点击的详情入口。
+- 每张图用一处主视觉焦点加两至三个信息区域,避免满屏等大的统计卡。内容真实感来自产品名、场景、负责人、时间、约束和原话。
+- 固定演示快照:36名模拟学员、6组、12个产品项目、72条创意、24张机会卡;项目进行中7、卡住3、课堂完成2;项目S3=2、S4=6、S5=4。
+- “模拟数据”必须清楚可见。所有评分、价格和验证结果是演示假设。基础包的380条社媒、400条商品、125条百克价估算、49条资料另以“用户提供归档”标记。
+- 不在效果图中使用真实员工手机号、账号、API配置或未提供的经营数据。不要画市场份额/销售额增长和“已上市”标识。
+- 中文优先,少量英文仅用于常见缩写。不添加预设之外的品牌口号、未经证明的健康声称或虚构标准编号。
+- 画系统界面,不画显示器外壳、桌面场景或手机。图中文字为设计稿,正式Demo必须用可选择、可访问的真实文本重建。
+
+## 2. 五张图的具体内容
+
+以下“可直接复制提示词”均需与以上统一要求一起使用。
+
+### A:创新驾驶舱
+
+文件建议:`A-创新驾驶舱-PC.png`
+
+可直接复制提示词:
+
+> 生成康师傅产品创新工作坊的PC端“创新驾驶舱”系统首页,高保真企业产品设计。使用统一的暖白、墨色与品牌红视觉。顶部为“产品创新工作坊”“第二天 · 下午”“模拟数据”。左侧导航:创新驾驶舱、情报洞察、产品共创、验证评审、课堂运营。主标题“12个产品方向,正在并行推进”,小字“6组 · 36名模拟学员”。顶部紧凑指标为“创意72”“机会卡24”“项目12”“卡住3”。页面中央以S3=2、S4=6、S5=4显示阶段分布,右侧是一张人群×场景机会矩阵,行含上班族、学生、户外、控卡、家庭、尝鲜。下方重点产品表至少6行,出现“工位轻香葱油拌面 S5 课堂完成”“宿舍轻辣番茄拌面 S4 卡住”“露营菌菇热汤面 S4 卡住”“一人食酱香大满足 S3 卡住”。资源请求区写“待核:热水复水路线”“待补:户外用水条件”“待区分:习惯搭配与份量补偿”。项目状态只代表课堂工作。展示精细表格、状态标签、负责人和查看详情入口,内容密集但可读,不放经营收入或市场份额数字。
+
+### B:情报与用户洞察中心
+
+文件建议:`B-情报与用户洞察中心-PC.png`
+
+> 生成同一套康师傅产品创新工作坊的PC端“情报与用户洞察中心”首页。统一侧边栏与暖白底,研究蓝辅助。醒目标注“模拟数据”。顶部有“模拟用户声音12,000”“结构化证据360”,旁边独立显示“用户提供归档:社媒380行 / 商品400行 / 知识条目49行”,两类数据不混合。主要筛选:人群、场景、平台、关联产品、审核状态。左中是痛点/需求聚类列表:不能开火、辣度不易判断、口味疲劳、份量与用途、价格口径;标签提示“AI归类建议,待复核”。中间是可读的用户声音表,右侧展开一条模拟证据:“中午只有热水,排队等微波炉太久。我会先看要不要煮,酱味太冲就留到下班再吃。”注明“模拟原话”“工位午餐”“用于P01”“来源与引用可追溯”。底部保留一条反向意见“愿意多煮几分钟换口感”,以及“125/400条有可用百克价估算,非成交价”的资料质量提示。不是社交媒体营销页面,不把词频或点赞画成市场需求比例。
+
+### C:产品共创工作台
+
+文件建议:`C-产品共创工作台-PC.png`
+
+> 生成同一套系统的PC端“产品共创工作台”,协作紫作为辅助色,醒目标注“模拟数据”。顶部切换“G2 学生宿舍与夜宵”“我的任务”“小组产品”。左主区两条产品线并行:“宿舍轻辣番茄拌面 S4 卡住”“夜宵芝士轻辣拌面 S5 进行中”。选中宿舍产品后,中央展示S1至S5阶段轨迹及本阶段三个任务:“核对无灶具条件”“补热水复水依据”“修订机会卡v2”。右侧为成员分工和Agent过程,明确节点为“检索资料 → 核对引文 → 提示缺口”,不要假装已经完成实物试验。下方展示机会卡,字段有目标人群、用餐目的、场景、证据、反证、待补项;示例价格“目标4.9元/份 · 演示假设”。评论写“有开水不等于适合泡制,先核面体工艺”“这条用户说加蛋是习惯,不应算份量不足”。出现“查看原文”“比较版本”“提交课堂作业”入口,界面像真实多人工作台,避免空聊天窗口占据全屏。
+
+### D:验证与评审中心
+
+文件建议:`D-验证与评审中心-PC.png`
+
+> 生成同一套系统的PC端“验证与评审中心”,验证青为辅助色,醒目标注“模拟数据 / 桌面评估”。筛选阶段S4与S5,项目列表10个,验证任务30条。选中“宿舍轻辣番茄拌面”。主区以验证矩阵呈现“人群场景适配”“制备方式”“辣度理解”“成本假设”“工艺条件”“证据充分性”,用已知/待补/存在冲突表达,不给未知字段填0分;总评写“资料未齐,暂不计算综合分”。关键冲突为“宿舍无灶具”与“候选面体需煮制”。右侧证据与反证并列,下面是待补问题表,含负责人、所需材料、处理状态和复审时间。底部有“继续桌面研究”“调整方案”“暂停推进”“记录评审意见”操作。展示另一项“高纤荞麦拌面:营养依据待核”,避免把非油炸直接等同健康。不要生成已通过企业审批、已量产或完成试吃的真实宣称。
+
+### E:课堂运营与成果中心
+
+文件建议:`E-课堂运营与成果中心-PC.png`
+
+> 生成同一套系统的PC端“课堂运营与成果中心”,琥珀辅助色,醒目标注“模拟课堂”。上方三段时间轴为“第一半天 市场与假设”“第二半天 洞察与机会”“第三半天 验证与概念”,夜间采集作为第一与第二段之间的小节点。主区是6组作业矩阵,列为S1报告、夜间资料、S2洞察、机会卡、S4评估、S5方案、路演,混合显示已交、待复核、补证、卡住。顶部清楚显示“36名模拟学员 / 6组 / 12产品 / 3卡点”。右侧教练待办按问题而非纯分数排序:“G2 制备条件冲突”“G3 户外用水待核”“G5 份量归因证据不足”。下方成果墙展示工位轻香葱油拌面、家庭双人小份组合的课堂成果,以及“路演5分钟+质询”“49天行动清单”。不要宣称所有组已完成,也不要把作业提交率当产品成功率。
+
+## 3. 发预览时附带什么
+
+每张图附:系统名称、给谁用、解决的问题、两条关键操作、是否由skill-image成功生成。发送实际图片和可访问文件链接,记录完整提示词与实际输出尺寸。
+
+先发五张首页图,再进入Demo制作。不把图片生成成功当页面交互已完成,也不把图里的按钮当成可以点击的功能。

+ 113 - 0
docs/innovation-dashboard-plan-20261103/04-五个系统介绍长页内容.md

@@ -0,0 +1,113 @@
+# 总介绍与五个系统介绍长页
+
+本文件是长页内容稿。后续由王刚001用Lit页面实现,嵌入实际生成的PC图,并提供进入相应Demo的入口。各长页用于解释系统价值与流程,Demo另提供真实可点击的列表、筛选、统计和详情。
+
+## 共同结构
+
+项目总览 → 核心用户与问题 → 系统PC预览 → 一条产品故事 → 关键功能与实际界面 → 上下游怎样衔接 → 数据口径与本期边界 → 进入Demo。
+
+五张PC预览可作为各自长页头图。不能把一张静态截图铺满整页后声称“长网页已完成”,下面必须有可阅读的真实文本与页面导航。
+
+## 总介绍页 `/showcase/`
+
+主标题:**康师傅产品创新工作坊**
+
+副标题:**让每个产品方向,都有证据、协作与下一步。**
+
+导语:在三个半天里,团队将创意拆成研究任务,从用户声音与竞品资料中形成机会,再把验证条件和取舍带进产品概念。五个工作视图共用一条产品线,领导看全局,学员做产品,教练追过程,专家看依据。
+
+页面内容:
+
+1. 一张流程总图:创意、搜集、洞察、机会卡、验证、概念、路演。
+2. 五端入口,各用一个明确的问题描述:看进展、找依据、做产品、做判断、管课堂。
+3. 三个半天的作业时间轴,说明夜间采集是任务执行,不是第四个半天。
+4. 演示规模说明:6组、36人、12个产品方向;明确这些是模拟设定。
+5. 展开三条故事线P01/P03/P10,说明顺利推进与合理暂停都能被系统呈现。
+6. 原始归档与模拟数据分开、课堂完成与市场验证分开的说明。
+
+主入口:进入五端Demo;辅助入口:查看数据口径、查看课堂作业。
+
+## A 创新驾驶舱 `/showcase/leadership`
+
+主标题:**把全员创意,变成看得清的产品组合。**
+
+导语:领导不必逐份打开报告,先看哪些人群和场景正在被探索、哪些方向已经进入验证、哪些缺口需要协调资源,再展开具体产品的判断依据。
+
+内容次序:
+
+- PC首页图与图旁的三处说明:项目漏斗、人群场景矩阵、卡点清单。
+- “12个方向,三种推进状态”:进行中7、卡住3、课堂完成2;说明它们是同一模拟快照。
+- 产品组合表:产品、目标人群、当前阶段、负责人、主要缺项、下一动作。
+- P03资源协调案例:从驾驶舱看到热水复水条件冲突,进入评审页,再落到工艺资料任务。
+- 取舍留痕:为什么继续、调整或暂停;综合分缺依据时显示待补,不用红绿灯代替解释。
+- 汇报出口:当前快照、阶段报告与49天后续计划;演示尚未接入真实经营数据。
+
+入口:查看项目组合 / 查看卡点。衔接:C共创工作台、D验证评审。
+
+## B 情报与用户洞察中心 `/showcase/research`
+
+主标题:**让每张机会卡,都能回到原始依据。**
+
+导语:把分散的社媒、电商、工艺资料和团队经验放到同一资料入口。研究者能检查原话、识别误标、查看反证,并复用到不同产品。
+
+内容次序:
+
+- PC首页图,标注资料列表、证据抽屉、质量提示。
+- 用户已提供的归档盘点:380行社媒、400条商品记录、49条知识资料,注明原始包和统计单位。
+- 六场景入口:上班族、学生、户外、控卡、家庭、地域尝鲜。每类给出拟研究问题,不把词典标签称调研结论。
+- 一条原话如何成为证据:保存来源、逐字引用、标明用途、人工复核、关联机会卡。
+- 质量控制实例:出现“辣”可能是正面喜好;“加蛋”可能是日常习惯;不同组合装价格不能直接比较。
+- 采集任务区:成功、失败、已复用和待补;不以空结果谎报完成。
+
+入口:打开资料库 / 查看证据链。衔接:C共创工作台、D验证评审。
+
+## C 产品共创工作台 `/showcase/collaboration`
+
+主标题:**多人同时做产品,每一步都有接力。**
+
+导语:每位学员能看到自己参与的产品、负责的研究任务和待回应意见。小组围绕同一张机会卡持续修改,证据与方案版本跟着产品走。
+
+内容次序:
+
+- PC首页图与项目切换、阶段任务、协作记录三处说明。
+- 6组、12产品的协作图:一个人可参加多个项目,一条资料可供多项目引用,负责人清楚。
+- 从创意到机会卡:记录方向目的、人群、场景、候选价格与证据缺口。
+- P01完整案例:S3基线、S4评估、S5方案v1/v2、课堂报告与保留的待补项。
+- 版本比较:改了什么、为什么改、依据是什么;旧成果保持原样。
+- Agent辅助区:显示检索、整理、核引文、生成草稿的实际任务状态,模拟回放明确标记。
+
+入口:打开我的工作台 / 查看机会卡。衔接:B情报、D评审、E作业。
+
+## D 验证与评审中心 `/showcase/validation`
+
+主标题:**把有吸引力的想法,变成有条件的判断。**
+
+导语:评审时同时看证据和反证,把制备、风味、规格、成本、工艺和合规的缺项放到明处。继续与暂停都需要具体理由。
+
+内容次序:
+
+- PC首页图,标注验证矩阵、冲突区和问题负责人。
+- 评估范围分层:桌面初评、用户研究计划、实物试验、企业评审;本轮只用模拟数据展示流程。
+- P03实例:宿舍无灶具与需煮面体的矛盾,提出替代路线或补证要求。
+- P10实例:把习惯配菜、吃后补主食和用餐目的分开记录,不给份量假设虚构支持。
+- 评审输出:继续、调整、暂停/否决及理由;待补材料、负责人、复核条件。
+- 模型透明:维度、权重/版本、证据来源可看,未知为待补,AI初评不代表企业批准。
+
+入口:进入评审队列 / 查看验证问题。衔接:C修订、A资源协调、E教练反馈。
+
+## E 课堂运营与成果中心 `/showcase/classroom`
+
+主标题:**三个半天,每组都有清楚的产出。**
+
+导语:课表、任务、提交、点评和路演放在同一条时间线上。教练按卡点分配辅导,而不只看谁交了更多文件。
+
+内容次序:
+
+- PC首页图与六组作业矩阵。
+- 第一半天:市场和竞品图谱、假设清单、验证纪律、夜间任务。
+- 第二半天:夜间验收、VOC洞察、机会卡v1、选题项目与S3基线。
+- 第三半天:S4评估、机会卡修订、S5概念与卖点、路演及49天行动。
+- 教练卡点实例:制备条件不符、资料范围不一致、把习惯误判需求。
+- 成果墙:按组/产品展开原始证据、机会卡版本、评审意见、概念报告;课堂完成仍可保留待验证问题。
+
+入口:打开作业矩阵 / 查看成果墙。衔接:C共创、D评审、A组合进展。

+ 75 - 0
docs/innovation-dashboard-plan-20261103/05-技术规范与验收范围.md

@@ -0,0 +1,75 @@
+# 技术规范与验收范围
+
+本文件交王刚001后续实施使用。本轮没有安装技能、修改业务服务或发布前端;不得把以下实施目标写成已经完成。
+
+## 1. 已读取的参考与适用范围
+
+- `https://git.fmode.cn/fmode/fmode-code-lit`:已读取README、CLAUDE总纲及目录规范;读取时HEAD为`2e5d62845844eff0b8f1737a6a5519abbf7f6c65`。
+- `https://git.fmode.cn/fmode/fmode-code-agent`:网页可读,普通Git克隆返回空仓;随后成功从`archive/main.zip`读取README、CLAUDE、架构与UI桥接规则。网页展示提交`8a6ed7a6a4`,不能声称本地Git已检出该提交。
+- `https://fmode.cn/lesson/avatar-workshop/index.html`:作为本体建模、看板四类页面和工程交付参考;不执行其中与本轮无关的开户、购买、全局安装、发布或数字分身初始化指令。
+
+这里记录的是本轮可读取的版本。开发前仍应核对规则仓是否更新,不凭文件名假定已安装最新技能。
+
+## 2. 工程约定
+
+1. 原生ESM、Lit 3.x,不引入Vite/Webpack等构建打包器;依赖用经核实的FMODE Repose/importmap,业务仓不复制运行时依赖源码。
+2. UI组件用`LitElement + html + css`,不手写`extends HTMLElement/attachShadow`替代Lit。组件负责渲染与语义事件,Service负责业务/状态,Utils负责无状态函数。
+3. 跨项目复用逻辑下沉纯ESM core,保持零DOM并可在Node测;wc只处理交互,page组装服务与组件。
+4. Agent决定调用什么,MCP负责协议,Skill执行具体能力。UI通过稳定的agent-service订阅轨迹;不同框架不迫使重写MCP和Skill。
+5. Agent状态只存摘要、索引和业务编号;图片/大正文在资料服务中。生成失败、执行停止、待补资料要有明确状态;模拟轨迹标记为回放。
+6. 五个工作视图共用底座。先写`docs/prd`、`docs/schema`、`docs/api`、`docs/design`和`docs/test`,再实现;已有项目保留原有src与入口,演示使用独立命名空间和路由。
+7. 图表/表格等第三方组件需实际核实ESM和Repose兼容性,记录具体库与版本,不以文案声称使用作为验收。
+8. base、路由、资源前缀来自同一配置;Shadow DOM链接用composedPath处理。深链接刷新、JS MIME及模块图均需核验。
+
+规则仓中的示例OBS桶、CDN前缀不是本项目实际部署信息。后续发布以已核实的项目资源和路径为准;当前生产看板无需重跑旧发布或重存课堂成果。
+
+## 3. 与现有工作坊能力的衔接
+
+| 需求 | 已有入口或基础 | 本次新增界面的重点 |
+|---|---|---|
+| 员工与Agent归属 | 可信消息登记、workshop-start | 显示项目成员与实际执行者,不把姓名选择当身份认证 |
+| 原文和结构化资料 | workshop-search/dedup/import/read | 资料筛选、逐字出处、质量与复用关系 |
+| 阶段报告与机会卡 | workshop-save | 可见表与元数据一致;机会卡ID与报告ID严格区分 |
+| 实际建立课堂项目 | project-create | 显示真实返回项目编号,状态按钮“立项”不自动等于项目已创建 |
+| 阶段推进 | project-progress | 使用合法命令名,按阶段成果检查;失败保留原请求键 |
+| 方案版本 | concept-save、project-status | 规格未知为null,空配方/工艺不冒充已研发完成 |
+| 公开浏览 | 现有公开投影与报告入口 | 五端均可公开查看,演示角色切换用于筛选 |
+
+问题负责人/状态、课表作业矩阵、协作分工、收藏/标注、评审队列等属于拟新增模型或展示能力,不能声称现网已有完整实现。当前Skill与后台不支持的动作应单列差距,不能直接发明CLI命令。
+
+## 4. 数据对象建议
+
+主关系:Workshop → Group ↔ Member;Group → Idea → Opportunity → Project;Project ↔ Member;Source → Evidence → Opportunity/Assessment;Project → Assessment/ConceptVersion/Report/Question;Assignment → Submission → Review;AgentTask → Artifact/Failure。
+
+建议字段:
+
+- 通用:`id / demoId / dataMode / provenanceType / isSynthetic / owner / groupId / projectId / version / status / createdAt / updatedAt`。
+- 原文与证据:`sourceId / rawText / quote / locator / sourceUrl / platform / capturedAt / verificationState / reviewState / supportsOrContradicts`。
+- 未决问题:`question / ownerMemberId / requiredMaterial / state / evidenceRefs / resolution / reviewedAt`。
+- 验证:`scope / method / modelVersion / dimensions / knownInputs / missingInputs / counterEvidence / recommendation`。
+- 作业:`halfDay / deliverableType / dueAt / submissionRefs / reviewNotes`。
+
+字段是拟议数据合同,不要求将全部字段直接塞进现有Kf类。接真实后端前先核合同并设计扩展/映射。数据模式与“归档是否真实核验”分开。
+
+## 5. 预览及Demo验收
+
+| 范围 | 可操作的验收标准 |
+|---|---|
+| 五张PC图 | 5/5实际生成、风格一致、中文清楚、同一快照数字一致、模拟标记可见;附提示词与实际输出 |
+| 总介绍与长页 | 1个总介绍、5个各自独立长页,图文完整,能跳到对应Demo,不仅是五张图片 |
+| 五端Demo | 每端都有概览、筛选列表、统计、详情,可从产品继续看证据/任务/报告;不是死链接或只弹提示 |
+| 多人多产品 | 36模拟成员/6组/12项目;切换组/成员/产品后数据正确,跨组引用与协作不串项目 |
+| 规模一致性 | 实际生成量与声明相符;KPI从数据计算。种子未扩展前不声称已有12,000条可检索数据 |
+| 关系一致性 | 所有引用存在,quote在rawText中;S3/S4不展示为已保存S5方案;8份方案仅属4个S5项目 |
+| 主流程 | P01顺利推进;P03工艺冲突受阻;P10证据不足留待补。三个案例可从不同端追到同一编号 |
+| 错误与空态 | 至少演示无结果、资料缺失、更新冲突、任务失败,错误后不假成功、不自动重做业务写入 |
+| 页面行为 | 桌面真实点击;手机至少检查长页、入口、详情及来源可读。检查深链接刷新、Shadow DOM链接、控制台与网络 |
+| 交付回报 | 报实际文件/仓库/版本、预览图、路由和检查结果;图片成功、文件上传、页面浏览器验收分别陈述 |
+
+数据集目标为12项目:S3=2/S4=6/S5=4;进行中7/卡住3/课堂完成2;机会卡24=选入项目12+候选6+否决6。报告44、方案8的计算见02。若后续调整快照,五张图、演示数据、长页文案同时更新。
+
+## 6. 本期边界
+
+本期是模拟展示与可点击流程演示。保留现有真实课堂链S5/completed;不使用它的编号充当新模拟对象,不把模拟资料混进既有live统计。
+
+正式企业审批、真实经营接口、实物试验、采购/招募、员工正式确认与正式报告发布不由这个Demo代办。公开看板保持无需登录,内部真实写入沿现有可信归属和服务端权限执行。

+ 26 - 0
docs/innovation-dashboard-plan-20261103/06-转给王刚001的消息.txt

@@ -0,0 +1,26 @@
+王刚001:请先读取随附《康师傅多端看板规划与设计交接》,本任务是给刘总展示三个半天、多人多产品并行的创新工作坊流程。
+
+先理解材料,按“先PC预览图,再可交互Demo,再各系统介绍长页”的顺序推进。当前交接包是方案与种子数据,不代表图或Demo已经做完。
+
+一、五个PC工作视图,共用数据底座:
+A 创新驾驶舱:领导看方向、进度、卡点与资源需求;
+B 情报与用户洞察中心:原文、竞品、规格/价格口径、聚类、证据和补料;
+C 产品共创工作台:成员/小组/多个产品、任务、机会卡、方案版本和协作;
+D 验证与评审中心:证据与反证、桌面初评、工艺成本缺口、修改和暂停理由;
+E 课堂运营与成果中心:三个半天、夜间任务、作业矩阵、点评、路演与49天行动。
+
+二、先读01—05号说明,以及原始资料四件。问卷是空白模板、追问单是问题,不是回收结果。基础包实际是260条分组笔记+120条竞品笔记、400条商品记录、49条知识条目;不能把笔记行数当人数、把估算价当成交价。125条有包内百克价估算口径,其余不能混算。
+
+三、先核你本机skill-image是否存在、实际入口与可用能力,按03号五张提示词生成PC效果图,每端一张,逐张发预览。不要让用户为此提供OPENAI_API_KEY,也不要把课程示例模型/命令硬套到本机。生成失败如实报告,不把未出图说成已完成。
+
+四、模拟设定为6组36人12产品;12项目阶段S3=2/S4=6/S5=4,状态进行中7/卡住3/课堂完成2。24张机会卡中12张选入项目、6候选、6否决。全部标“模拟数据”。完整Demo扩展目标及12个产品故事线见02;preview-seed.json是种子,未扩展前不能声称已有12,000条可检索声音。
+
+五、随后按fmode-code-lit与fmode-code-agent两个规则仓开发:先读最新总纲,原生ESM、Lit、无构建,组件/服务/工具分层,Agent/MCP/Skill分层。先写数据与接口合同;已有工程保留入口,新演示用独立命名空间,不能覆盖当前生产课堂链或导入到live数据。
+
+六、Demo每端都要有概览、筛选列表、统计、详情和可用跳转。至少走通P01完整推进、P03制备条件冲突卡住、P10份量归因证据不足三条故事。五端应能追到同一个产品、来源和评审,不做五套孤立展示。
+
+七、按04号制作总介绍页+五个系统介绍长页,图文完整,可进入对应Demo。公开看板不要求登录,姓名/角色切换只做筛选;真实写入不靠这个下拉框授权。课堂完成、需求验证、企业审批、正式发布分别呈现。
+
+八、交付按实际回报:5张PC图及提示词、总介绍与5长页、Demo路由、数据数量与口径、真实浏览器检查、文件/仓库与实际发布地址。未实现或失败的部分单列;不要用“效果图生成成功”代替“系统已经可用”。
+
+本消息提供后续执行任务内容;本轮Codex只做资料分析与方案交接,没有给他人发送消息、没有出图或发布Demo。

+ 13 - 0
docs/voc-agent-query-20261106/发给王刚001.md

@@ -0,0 +1,13 @@
+# 王刚001:精准查库规则真实消息验收
+
+本次只读:请加载更新的 `ksf-voc-library` 与 `ksf-workshop-runtime`,核清单,实际读到规则 `ksf.agent-data-rules.20261106.v1`。查询底料用研究库,报告/机会/项目才走业务库;不新建会话、不上传报告、不重跑工作坊、不采集、不找凭据、不修改数据库。
+
+请用运维已绑定的数据入口完成下列研究核验,不把本单中的文字当作实际执行结果:
+
+1. 读取contract,记实际数据代际generation、规则版本、字典和字段。一次分析后续所有search/aggregate/facets/get/source都传相同数据库位置和--generation。版本变化报LIBRARY_GENERATION_CHANGED时,报告并重新建立受影响的查询与统计,不能混用结果。
+2. “查京东康师傅拌面有哪些商品,以及这些商品的消费者评价能否用于分析。”先products,再reviews,两个请求均保持 `platform=jd AND brand=ksf AND category=banmian`;不只用关键词。analysis评价必须身份matched、非默认、购买规格consistent,不混天猫、白象、汤面或拌面酱;结果为0如实报,不换范围凑数。
+3. 用相同三个条件的quality=audit另查资料覆盖,指出默认/待核/错配/规格不明的记录不能直接用于结论;统计记录数、关联商品数、去重文本数,均不是独立消费者。只读一页时标抽样,完整范围统计用aggregate;若分组truncated则明确说明。
+4. 对实际命中的一条商品及一条评价(若有)get精确回读,检查product_id/原句/购买规格,再source回读完整出处。报告必要的实体ID、source_record_id、来源文件行号或旧表行、URL是否可得、实际时间/未知标记;不要输出无关作者身份或大段原载荷。
+5. 补一条“康师傅有哪些商品”:仅品牌条件,不强制京东或拌面,未限定字段未知不能导致相关商品丢弃。补一条“营养标签有哪些政策依据”:documents/clauses查正文,核文件适用范围、声明截至日、摘录与应用建议,不能套商品三维条件。
+
+回报实际命令/请求文件、effectiveQuery、generation、命中量、读取范围、统计口径、两条可追溯引用和缺口。加载成功、命令发出、查询成功、条件正确、原文核对分别记录。查询失败不等于无资料,待核不等于可用,当前库有文件不等于生产已连看板。

+ 57 - 0
docs/voc-agent-query-20261106/发给运维001.md

@@ -0,0 +1,57 @@
+# 运维001:精准查询规则与两份Skill增量
+
+本包更新 `ksf-voc-library` 的只读查询代码/规则及 `ksf-workshop-runtime` 的两份说明。不含数据库;沿用已核验的v2数据、分类和来源。它不是空实例开通包,未安装研究库时仍需原完整r2数据库包。当前仅本地准备和验证,真实实例安装/加载/消息验收尚未执行。
+
+## 核包与安装
+
+1. 按 `delivery-receipt.json` 核ZIP字节、SHA256。解包到 `/opt/data/ksf-query-skills-20261106`,不放在Skill、研究库或员工状态目录内。以hermes运行用户操作;目录0700、文件0600;核根 `FILES.sha256` 与两份Skill清单。不安装包外材料,不读取或转发密钥。
+2. Python>=3.11且支持SQLite FTS5。保留两个既有Skill、凭据绑定、员工状态和当前库指针;先只读计划:
+
+```sh
+python3 -B /opt/data/ksf-query-skills-20261106/install-query-skills.py \
+  --package /opt/data/ksf-query-skills-20261106 --check
+```
+
+从11月5日已准备r2升级时,工作坊恰3项:SKILL.md、references/workshop-tasks.md、FILES.sha256;运行脚本不变。研究Skill恰6项:SKILL.md、references/retrieval-rules.md、新增references/data-contract.md、scripts/voc_db.py、scripts/retrieval.py、FILES.sha256。全包共核21个Skill文件。已有11月4日工作坊也在精确基线内;若研究Skill不存在,首次安装10个Skill文件,但数据库仍须独立检查。
+
+出现UNKNOWN_SKILL_BASELINE时停下核现有文件,不覆盖、不自行修改清单放行。额外身份/凭据文件不在受管清单内,不删除。核计划符合后执行一次:
+
+```sh
+python3 -B /opt/data/ksf-query-skills-20261106/install-query-skills.py \
+  --package /opt/data/ksf-query-skills-20261106 --apply
+```
+
+工具备份放 `/opt/data/ksf-query-skills-backup-*`,在Skill目录之外;回报实际changed、backup、权限/属主、stderr和exitcode。安装不触碰数据库、不改网关、不重启,discoveryVerified仍为false。
+
+## 对齐实际数据库
+
+核对看板服务的 `KSF_LIBRARY_REGISTRY`,将**同一已授权目录**用于Agent查询进程。优先显式命令参数,不为此改全局网关或复制数据库。下列 `/实际共享代际目录` 必须替换为已核实路径,不能照抄猜测:
+
+```sh
+python3 -B /opt/data/skills/ksf-voc-library/scripts/query_voc.py contract --registry /实际共享代际目录
+python3 -B /opt/data/skills/ksf-voc-library/scripts/query_voc.py verify --registry /实际共享代际目录 --generation 实际contract返回值
+```
+
+无registry、仍使用独立静态库时,用 `--db /实际library.sqlite` 逐命令显式指定,并报告尚未对齐看板;不能因有旧默认文件就宣称已查当前全部资料。registry与db不可同时传,指针不可越出registry。一个研究任务每次命令都带相同 `--registry`(或--db)与 `--generation`。
+
+contract应返回schema=ksf.voc-library.v2、retrievalContract=ksf.voc-retrieval.v2、agentRulesVersion=ksf.agent-data-rules.20261106.v1。读取counts、generation,不把历史数量写死为未来库的验收值。verify、查询与回读应readOnly=true、databaseWrites=0。
+
+## 最小只读回读
+
+用包内 `examples/` 执行search,传同一数据库定位和generation;核effectiveQuery及每条platform/brand/category。
+
+- `01-jd-ksf-banmian-products.json`:京东康师傅拌面商品。
+- `02-jd-ksf-banmian-reviews.json`:同范围有效评价,规格必须consistent;即使命中0也不切换范围。
+- `03-jd-ksf-banmian-review-audit.json`:同范围审计,不能计成有效观点。
+- `04-brand-products.json`:只限品牌,不硬加平台品类。
+- `05-nutrition-documents.json`:政策正文,核摘要/建议与原文边界。
+
+关键结果get、source回读并核实体、商品/文档ID、原句、来源定位和时间。以实际条件用aggregate统计,不把一页当全量。查询前后数据库SHA一致;本地verify报告不是线上生产安装回执。
+
+## 加载与回退
+
+按实例既有加载方式让下一回合发现两份Skill,再由用户把《发给王刚001》转入真实企微消息。不要代替员工触发,不新建会话、不重传报告、不采集、不调用付费上游。
+
+回退先核当前各文件仍等于本包清单,恢复backup对应文件;本版新增文件仅在仍匹配清单时移到受控隔离目录。保留并发修改、凭据绑定、员工档案、会话、原数据库与指针。不用回退数据库或业务报告。
+
+分层回报:包核验/文件安装/实际数据定位和代际/CLI查询与来源/Agent加载/真实消息;browserVerified保持原真实状态,不从CLI推断生产浏览器通过。

+ 23 - 0
docs/voc-agent-query-20261106/数据库与Agent规则.md

@@ -0,0 +1,23 @@
+# 数据库精准查询与Agent规则 · 2026-11-06
+
+采用现有结构化研究库,更新配套查询Skill与工作坊Skill,不重建数据库。完整结构、分类、来源和后续更新规则见 [数据合同](../../skills/ksf-voc-library/references/data-contract.md),实际能力由query_voc.py contract返回。
+
+| 层 | 如何保证 |
+|---|---|
+| 数据结构 | 商品、规格、价格、评价、社媒、政策、条款、榜单、覆盖分表,稳定ID与外键关联;来源原件和采集请求独立留存 |
+| 分类 | 平台/品牌/品类/店铺独立字段,稳定字典;未知/冲突留待核,来源属性与标题规则分级 |
+| 查询 | 用户明确条件AND,未限定维度不强加;字段索引+中文FTS候选+字面复核,统计优先aggregate |
+| 评价分析 | 同平台族与商品ID精确关联,限定品类时购买规格也须consistent;默认、错配、待核单独审计 |
+| 可追溯 | 结论→实体→来源记录→文件/行/原载荷→采集请求/ZIP;保留时间与未知,不编URL |
+| 一致性 | Agent与看板使用同一registry/current.json;分析固定generation,更新后不拼接旧结果 |
+| Agent习惯 | 研究底料先查研究库,已有成果查业务库;核actual effectiveQuery、原文与来源后再分析,缺口才补采 |
+
+例:“京东康师傅拌面评价”的请求是:
+
+```json
+{"entities":["reviews"],"filters":{"platform":"jd","brand":"ksf","category":"banmian"},"quality":"analysis","limit":20}
+```
+
+只问品牌时只限品牌;问政策、价格、配方、原料、工艺时分别按实际数据目录和关系选查询路线,不把这个示例固定成万能三字段表单。无现成事实不能由营销标题推导配方/功效。
+
+本地准备不代表王刚001已经使用新规则。交付:两份Skill增量ZIP+[运维安装单](发给运维001.md),安装后用户再转[王刚001验收单](发给王刚001.md)。后续只新增数据时按现有代际导入流程更新,Agent的新研究读取新代际;新增字段、类型或分类定义时才更新合同与Skill。

+ 45 - 0
docs/voc-database-delivery-20261106/发给运维001-数据库分包投递.md

@@ -0,0 +1,45 @@
+# 运维001:改走两个小于20MB的ZIP附件,合并后沿用原8步
+
+你的回报确认:原数据库压缩包尚未收到,安装未开始。原包实测25,913,256字节,不是118,064字节的查询规则包。为避开所述单文件限制,本次只改变传输包装,不改数据库、不改Skill、不改安装器与原回执。
+
+**用户须在企微实际附上这两个ZIP文件,不能只转发本机文件路径、Codex链接或说明文字。** 两个附件都有内含数据库的原ZIP片段:
+
+1. `ksf-voc-database-20261106-r1-part01.zip`
+2. `ksf-voc-database-20261106-r1-part02.zip`
+
+它们是普通ZIP,均小于20,000,000字节。任一包内都有相同的合并脚本、TRANSPORT.json与本说明;每包另外含一个不同的 `.part01` / `.part02` 片段。两包都要到齐,不能把任意一个当完整数据库包安装。
+
+## 接收与合并
+
+目标仍为王刚001实例 `agent-node-u17879018108`。沿用已建好的容器 `/opt/data/inbox/`(0700、hermes)。如附件先在运维实例缓存,使用既有受控宿主通道投到目标inbox;从宿主放入时修正属主为hermes、文件0600。不要把数据上传公共链接,也不用新登录态或token。
+
+先回报**两个实际接收文件**的路径、字节数、SHA,与随附传输回执比对。附件缓存可能改名,以下两个输入路径请替换成实际值,不再扫描研究库或重复调用contract证明同一个缺文件问题。
+
+以hermes身份在目标容器运行;先检查下面的辅助目录尚不存在,再在 `/opt/data/inbox/` 下新建独立0700目录。使用 `umask 077`,只从任一包取出 `reassemble-voc-database.py`,不执行采集原包里的任何代码。可用Python标准库读取ZIP中的该固定脚本,写到该辅助目录(0600);无需pip或unzip软件。
+
+例如已把两个包放到inbox并将脚本取出为 `/opt/data/inbox/ksf-voc-transport/reassemble-voc-database.py` 后:
+
+```sh
+python3 -B /opt/data/inbox/ksf-voc-transport/reassemble-voc-database.py \
+  --part /opt/data/inbox/ksf-voc-database-20261106-r1-part01.zip \
+  --part /opt/data/inbox/ksf-voc-database-20261106-r1-part02.zip \
+  --output /opt/data/inbox/ksf-voc-database-20261106-r1.zip
+```
+
+脚本直接读取两个ZIP,不必先解出二十多MB片段;输入先后顺序不限。逐片核字节数、SHA与ZIP CRC,完整核原ZIP SHA后才以不覆盖的方式落地。原ZIP已存在且完全相同则unchanged;不同已有文件、缺包、重复包、损坏包或软链路径均停止。合并不是安装,`installed=false`;会新建原ZIP,不能说文件系统零写入。
+
+预期合并结果:
+
+```text
+file:   /opt/data/inbox/ksf-voc-database-20261106-r1.zip
+bytes:  25913256
+sha256: 1ba1ac2fa6a8b774f8945d88e014c07dd5e03332792af298a41f015ba6946f9f
+```
+
+输出原ZIP字节与此前交付完全相同。随后按你已列的**原8步**继续:核原ZIP → 解包核根16/16 → 库本体98,918,400字节与SHA → --check → --apply一次 → 新Skill contract/verify/9例及来源回读 → 身份/会话/Skill指纹保持 → 分层回报。无需重新等授权,不装旧r2 Skill,不改网关、不重启、不采集、不重存报告。
+
+原包内自带 install-voc-database.py、verify-installed-voc-database.py、DATABASE.json、delivery-receipt.json及完整执行单。只新建默认 `/opt/data/ksf-voc-library/library.sqlite`,已有不同文件/指针则停止,不覆盖。
+
+请先回报“分包是否都收到、合并SHA是否正确”,再回报数据库安装和只读回读。只有目标容器实际 `ls/stat`、库SHA和contract成功后,才称库已安装。之后由用户把原《给王刚001-数据库就绪后继续验收》发到真实企微消息,王刚从contract继续五步。
+
+**看板部署是另一条任务:** 继续执行看板部署单。不要把王刚容器安装这份库当成生产看板已经接库;看板宿主仍按部署单核实际私有数据路径、只读服务、同源授权与生产浏览器。

+ 69 - 0
docs/voc-database-delivery-20261106/发给运维001-补装研究数据库.md

@@ -0,0 +1,69 @@
+# 运维001:只补装研究数据库,保留11月6日两份新Skill
+
+已收到回报:两份Skill安装及规则加载通过,`DATABASE_NOT_INSTALLED` 属实。此前118,064字节的包是**查询规则增量**,不含数据库。本次提供 `ksf-voc-database-20261106-r1.zip`,内含数据库本体、独立安装器、9个查询样例及安装后验收器;没有Skill或旧版Skill安装器。
+
+本轮明确操作:在王刚001已授权实例 `agent-node-u17879018108` **只新建** `/opt/data/ksf-voc-library/library.sqlite`,保留两份已安装Skill、凭据、员工档案、会话索引及业务报告。无须通讯录、上游采集或业务库token。所有操作以hermes运行用户执行,不改网关、不重启、不执行旧r2整包安装器。
+
+## 1. 核验与解包
+
+- ZIP字节及SHA以随附 `delivery-receipt.json` 为准;核根 `FILES.sha256`。数据库本体为98,918,400字节,其完整SHA和代际在 `DATABASE.json`,不把ZIP的SHA当数据库SHA。
+- 解包到 `/opt/data/ksf-voc-database-delivery-20261106`,目录0700、文件0600、hermes属主;包、研究库、Skill不能互相嵌套。回执另放 `/opt/data/ksf-database-install-20261106/`(0700/0600),不放在包内以免改变包清单。
+- Python>=3.11,SQLite支持FTS5;不pip安装、不执行采集原包中的脚本。
+- 本包取自Codex本地看板当前代际,数据库字节与本地看板完全相同。它是已有王勇资料的结构库,不是新采数据,也不是远端生产看板已接库的证据。
+
+## 2. 只读计划 → 应用一次
+
+```sh
+python3 -B /opt/data/ksf-voc-database-delivery-20261106/install-voc-database.py \
+  --package /opt/data/ksf-voc-database-delivery-20261106 --check
+```
+
+预期readOnly=true、operation=install-database、databaseChecked=true,目标为默认路径library.sqlite;无文件写入。安装器核数据库SHA、schema/代际/原ZIP指纹、完整性、外键、表数与FTS5。已有不同数据库、registry或其他内容则停止,不覆盖、不删目录、不修改现有指针。
+
+核对计划后执行一次:
+
+```sh
+python3 -B /opt/data/ksf-voc-database-delivery-20261106/install-voc-database.py \
+  --package /opt/data/ksf-voc-database-delivery-20261106 --apply
+```
+
+先在目标外暂存完整文件、校验后目录重命名落地,再回读。成功应applied=true、filesystemVerified=true;目录0700、文件0600、hermes属主。重复运行同一包应operation=unchanged/applied=false,mtime不变。databaseWrites=0指未执行SQL写入;安装会新增数据库文件,不能汇报为文件系统零写入。
+
+## 3. 独立回读与9个样例
+
+先用已安装的新Skill,**不使用旧r2包里的查询代码**:
+
+```sh
+python3 -B /opt/data/skills/ksf-voc-library/scripts/query_voc.py contract \
+  --db /opt/data/ksf-voc-library/library.sqlite
+```
+
+schema应为ksf.voc-library.v2,检索ksf.voc-retrieval.v2,规则ksf.agent-data-rules.20261106.v1。读取真实generation,后续每个查询带相同--db与--generation。
+
+包内验收器会调用**已安装**Skill完成contract → verify → 9个样例(03/08为aggregate,其余search)→ 商品和评价get/source → 旧generation拒绝;不改Skill或数据库:
+
+```sh
+python3 -B /opt/data/ksf-voc-database-delivery-20261106/verify-installed-voc-database.py \
+  --package /opt/data/ksf-voc-database-delivery-20261106 \
+  --out /opt/data/ksf-database-install-20261106/readback.json
+```
+
+--out文件必须事先不存在。若已有运行环境设置了KSF_LIBRARY_REGISTRY,先报告实际路径并核用途;不覆盖它、不静默取消它去强行跑本包。当前已报环境变量为空,本轮可直接用默认路径/显式--db,无须改配置。
+
+本快照实际数量:商品4,639、评价2,360、价格观察4,070、规格4,639、社媒3,814、政策25、条款31、检索投影21,108;不等于有2,360条可分析观点。严格京东×康师傅×拌面:商品96、analysis评价18;只限品牌商品416;营养标签文件/条款命中11。以DATABASE.json和当前返回核对,不把历史数量当未来库的固定断言。
+
+回读前后数据库SHA相同,两份Skill清单仍为研究9/9、工作坊10/10;manifest自身分别4da410bb…、d66e5054…。核凭据绑定和btrFqkz0yw会话索引仍是上次指纹,不打印明文。不因业务库token到期而停止本地研究查询;本地查询不使用该token。需要上传报告时再按原受控授权流程处理业务库凭据。
+
+## 4. 看板对齐的真实口径
+
+本次选择**独立静态快照**,因为远端没有已核实的共享registry。安装后可以说“王刚001可查询本次已安装资料,快照与Codex本地看板同代际、同SHA”,不能说“远端生产看板已连接”或“后续会自动同步”。两个机器写相同目录名不等于同一存储。
+
+后续若统一使用中心registry,先确认授权服务或宿主共享挂载,两端实际解析相同代际与SHA、更新后都能读取新版本,再迁移查询入口;不能给一个并不存在的registry路径。当前不部署此能力、不修改生产看板或nginx。
+
+## 5. 加载、回报与回退
+
+数据库落地后现有Skill新命令即可读默认路径,无须重装或重启。用户再把《给王刚001-数据库就绪后继续验收》发到真实企微消息,继续此前五步。
+
+分层回报:包核验/数据库文件安装和权限/CLI合同与实际代际/9例查询与来源回读/Skill文件保持/真实消息待验。保留stdout、stderr、exitcode;查询失败与零命中分开。
+
+本轮只新建库,没有旧库需回退。确需撤回时先停用该快照,核文件仍等于本包SHA且未产生新指针/其他内容,再把本次新建目录移到 `/opt/data/` 内受控隔离位置;不删除或回退两份新Skill、身份、会话或业务报告,不覆盖并发变更。

+ 13 - 0
docs/voc-database-delivery-20261106/给王刚001-数据库就绪后继续验收.md

@@ -0,0 +1,13 @@
+# 王刚001:库装好后继续此前五步,无须重装Skill
+
+上次“两份Skill已加载,研究库未安装”的回报有效。运维完成本次数据库安装及回读后,请沿用11月6日新Skill,用当前真实消息继续此前验收。
+
+入口为已安装静态快照 `/opt/data/ksf-voc-library/library.sqlite`,不是虚构的registry;每个命令显式传 `--db /opt/data/ksf-voc-library/library.sqlite`。先contract取得generation,再为本次所有search/aggregate/facets/get/source传同一--generation。无需业务库token、昵称、会话或通讯录;不新建会话、不上传、不采集。
+
+1. contract核schema、规则版本、实际generation与counts,失败仍如实报环节。
+2. “京东康师傅拌面商品及其消费者评价”:products、reviews都保留platform=jd AND brand=ksf AND category=banmian;analysis评价须matched、非默认、购买规格consistent。本快照预期商品96、有效评价18,以实际回执核对,不换范围补数。
+3. 相同三条件单独quality=audit检查覆盖;aggregate区分记录数、关联商品数、去重文本数,不当独立消费者数。只读一页就报抽样,不称全量分析。
+4. 实际命中商品和评价各取一条,get精确回读,再source核原句、购买规格、来源文件/行号、可得URL与时间。不得将旧失败回执覆盖成成功;这是新的实际执行。
+5. 再查“康师傅有哪些商品”,只限品牌;再查营养标签政策正文与条款。未限定维度不硬加,应用建议不冒称法规原话。
+
+回报实际effectiveQuery、generation、命中量、读取范围、统计口径及两条证据定位。不输出大段原始载荷、无关作者身份或凭据。说明这是本次已安装快照;尚未对齐远端生产看板,后续新增数据未设置自动同步。Skill加载、查询成功、条件正确、原文核对和生产浏览器分开记录。

+ 27 - 0
docs/voc-structure-20261105/发给王刚001.md

@@ -0,0 +1,27 @@
+# 王刚001:用不同研究问题验收r2精准查库
+
+前提是运维已安装完整r2数据库、ksf-voc-library并完成工作坊三文件升级。未装/无权/失败如实报告,不能说库内无数据。本轮只读:不采集、不上传报告、不新建会话、不询问称呼、不改身份。不要只照抄包内本地测试数字。
+
+## 实际加载
+
+实际读 `/opt/data/skills/ksf-voc-library/SKILL.md` 和references/retrieval-rules.md,调用contract,报schema、retrievalContract、generation与清单指纹。解释:问题决定数据路线;明确条件保留;未限定维度不强制填写;关键结论需原文和来源。旧query三维模板不作通用入口。
+
+## 三个真实问题
+
+1. **库里有哪些康师傅商品?** 用search只限定brand,不强加平台/品类。完整分页,分别报商品总数及品类未知数,列前3个真实商品ID。用facets或aggregate查看渠道/品类分布,未知不能静默删除。
+2. **已有评价里有哪些关于面量、份量的反馈?** 用reviews正文检索portion_size概念,核回执展示的展开词。完整翻页,分别报记录数、去重文本数、关联商品数。选两条get/source回读原句、购买规格、日期、商品归属和来源。不要把所有命中都算负面,也不把相同正文的多个评价ID当成更多独立消费者;不输出作者身份。
+3. **营养标签相关政策依据在哪?** 用documents/clauses正文检索“营养标签/营养成分表”,回读至少1份文档和相关条款(若确有匹配),报政策状态截至日、原文定位、来源SHA及excerpt/application_note区别。应用提示不当法规原话;无匹配条款就明说。
+
+可直接使用包内examples/01-brand-products.json、04-portion-reviews.json、09-nutrition-policy.json;每个问题先简述实际计划,再真正调用,不只复述规则。请求写在/opt/data内,调用安装的query_voc.py search/facets/aggregate/get/source。一个范围的游标不得拿给另一范围。
+
+## 再验一个明确约束
+
+查询“京东、康师傅、拌面商品”,用examples/03-jd-brand-category.json;effectiveQuery须同时为jd、ksf、banmian,不出现其他品牌/平台/品类。与第1问范围不同,不能混数;限定品类的评价还须购买规格明确符合。按用户问题决定约束,不是每次都填三维。
+
+## 回报
+
+每问附实际输入/生效条件、调用是否发出、真实返回、实体计数/分页终点、2—3个证据编号及出处、时效和缺口。回执尽量给关键字段,不贴大段原始载荷。查询前后数据库SHA一致,应readOnly=true/databaseWrites=0。
+
+够用复用,缺口先在原范围补查别名、正文、关联和覆盖;查询失败不是没数据。没有连续历史快照不证明价格趋势/增长,详情页榜单标识不当完整Top100;标题重量只是候选,不算已核每100g价格。社会内容的品牌是提及关系,不能当商品归属。
+
+真实加载、真实查询与来源核对分别写通过/待验。本库是已采研究资料,业务报告/机会/项目仍查workshop-search/read;本次没有发布生产Parse数据或公开看板。后续成果需要入业务库时按既有合同查重、入库、回读并保留实体ID/source_record_id/ZIP指纹。

+ 50 - 0
docs/voc-structure-20261105/发给运维001.md

@@ -0,0 +1,50 @@
+# 运维001:r2问题导向研究库与查询Skill安装
+
+请使用完整 `ksf-voc-library-20261105-r2.zip`,替代本地准备的r1。重点是支持不同研究问题的精准检索,平台/品牌/品类只是可选条件。当前仅本地构建测试完成;不把本单当作已安装回执。
+
+## 1. 整包、环境和权限
+
+仅向王刚001已授权实例安装,不扩到其他数字人。解包到 `/opt/data/ksf-voc-delivery-20261105-r2`,不要位于站点、Skill或库目录内部。以运行用户hermes操作,私有目录0700、文件0600,回报实际属主权限。
+
+先核ZIP字节/SHA,再 `sha256sum -c FILES.sha256`。嵌套Skill清单也核对。Python>=3.11,必须支持SQLite FTS5;无需pip安装或上游密钥。原始391MB采集ZIP保留原件,不执行其中脚本。25份政策全文、主体原始载荷、原文件和请求定位已随结构库保存,完整外部原响应仍按原ZIP哈希定位。
+
+```sh
+cd /opt/data/ksf-voc-delivery-20261105-r2
+python3 -B ops/install_voc.py --package /opt/data/ksf-voc-delivery-20261105-r2 --check
+python3 -B ops/install_voc.py --package /opt/data/ksf-voc-delivery-20261105-r2 --apply
+```
+
+默认新建 `/opt/data/ksf-voc-library/library.sqlite` 与 `/opt/data/skills/ksf-voc-library/`。完全相同则no-op,不同已有文件则停止;不能覆盖已装r1或并发更改。若r1实际已装,先报告现有代际/指纹,另建r2目录核验后再安排显式切换,不让r2脚本读取r1库。包、Skill、库三者不能互相嵌套。
+
+## 2. 现有工作坊说明升级
+
+完整材料在materials/,从已报11月4日版本或精确匹配的本地r1准备版升级,预期恰3项:SKILL.md、references/workshop-tasks.md、FILES.sha256。其余8个内容文件、凭据绑定、员工/Agent档案和会话索引不变。
+
+```sh
+node materials/scripts/install-workshop-skill.mjs --check
+node materials/scripts/install-workshop-skill.mjs
+```
+
+其他变更/基线冲突停下核对。升级器备份在Skill之外,留真实回执。不要装平台桥、回退适配器热修、改网关或替换整个Hermes配置。
+
+## 3. 安装后独立回读
+
+```sh
+python3 -B /opt/data/skills/ksf-voc-library/scripts/query_voc.py verify
+python3 -B /opt/data/skills/ksf-voc-library/scripts/query_voc.py contract
+python3 -B /opt/data/skills/ksf-voc-library/scripts/query_voc.py search --input /opt/data/ksf-voc-delivery-20261105-r2/examples/01-brand-products.json --explain
+python3 -B /opt/data/skills/ksf-voc-library/scripts/query_voc.py search --input /opt/data/ksf-voc-delivery-20261105-r2/examples/04-portion-reviews.json --explain
+python3 -B ops/verify_retrieval.py --db /opt/data/ksf-voc-library/library.sqlite --out /opt/data/ksf-voc-r2-readback.json
+```
+
+以delivery-receipt.json与receipts/retrieval-validation.json的实际代际、数量和13类查询结果为准。库应为ksf.voc-library.v2,检索合同ksf.voc-retrieval.v2;商品4639(主导出4635+恢复历史引用4)、评价2360、社媒3814、政策25、条款31;检索投影21108条。789非默认评价不等于789条有效观点。分类待定1909条不全局丢弃:仅限定字段未知才无法满足该字段条件。
+
+核关键查询命中相应组合/商品索引,正文查询用FTS再字面复核;核实际effectiveQuery、计数和来源。整个查询应readOnly=true、databaseWrites=0,查询前后SQLite SHA一致。CLI成功不等于Agent实际加载,生产浏览器与公开看板均未接入本库。
+
+## 4. 加载、回报与回退
+
+确认下一回合能发现两个Skill。未发现按既有加载规则核查,不预设重启。真实消息验收单由用户转王刚001,不代发消息、不重跑工作坊。
+
+分层回报:整包核验/权限与新库安装/工作坊三文件/CLI与13类场景回读/Agent真实加载/真实问题查询。文件安装成功不能报真实消息已通过。
+
+回退时先停止使用该版,核文件仍匹配本次manifest后移到受控隔离目录;工作坊只恢复实际备份的三文件,不覆盖并发改动。不同代际先构建验收再安排切换,保留来源、分类规则和旧库。全程不改生产云函数、Schema、ACL、静态文件、员工身份或业务报告,不调用上游采集、不收费、不配通讯录。

+ 59 - 0
docs/voc-structure-20261105/数据库与Agent规则.md

@@ -0,0 +1,59 @@
+# 王勇 VOC 资料:结构化数据库与 Agent 合同
+
+当前修订为r2。核心是“研究问题→数据目录与关系→真实约束→字段/正文/同义词检索→统计/来源回读→证据判断”,不把平台/品牌/品类样例当所有任务的固定表单。研究查询新增search、facets、aggregate,并索引商品、评价、价格、规格、社媒、政策全文、条款、榜单标识和采集覆盖。25份政策原文文本实际入库;完整行为合同见配套Skill的references/retrieval-rules.md。
+
+本轮以用户交付的原始 ZIP 为不可变来源,已实现并实际构建独立研究结构库。生产业务库与看板暂未写入。研究库采用 SQLite 只读分发,适合当前万条级资料与现有数字人进程直接检索;不要求另开采集服务或配置上游密钥。工作坊既有 Parse/PostgreSQL 继续存报告、机会卡、证据及项目,两者职责不同。
+
+## 一、结构与主键
+
+| 数据 | 表 | 唯一/关联规则 | 查询/质量要点 |
+|---|---|---|---|
+| 批次与原文件 | batches/assets/source_records/source_links | ZIP SHA;包内路径+SHA;JSONL行号,或历史SQLite表+row ID | 不执行包内脚本;主JSONL只入一次,历史副本不重复计实体 |
+| 采集请求 | collection_requests | 原始响应文件路径 | 接口ID、run、上游请求ID、白名单参数、状态、请求/快照时间;失败响应只留回执,不产生有效实体 |
+| 品牌/品类字典 | brands/categories | 稳定code;别名明示;品类父子关系 | 仅匹配本次实际约束;不自动扩子类;分类更正需来源与规则版本 |
+| 商品链接 | products | platform_family+item_id | marketplace/brand_code/category_code/shop_id独立列,不能混平台、按同名合并或由店铺推定品牌 |
+| 分类依据 | classification_assertions | 商品+维度 | 来源属性或标题规则、证据字段、来源记录、规则版本;未知/混合保留待核对 |
+| 重复观察 | product_observations | 商品+来源文件/时间/关键词/取得方式 | 同一商品多次看到不算多个商品或新增 |
+| 规格 | specs | 商品+规格记录;可空上游SKU | 标题解析只算候选,未知/赠品/总重不猜;物流weight不当净含量 |
+| 价格快照 | prices/price_sources | 商品+口径+金额+来源时间+原始响应;多来源另关联 | 金额用人民币分,范围/遮挡价留原值;观察日/请求日分开,未核重量不算每100g价格 |
+| 电商评价 | reviews | platform_family+item_id+review_id;FK→商品 | 评价ID字符串,正文/追加/原评分体系/购买规格/原日期分开;默认文本、错配、规格冲突分别标记 |
+| 社媒 | social_posts/social_brand_mentions | 平台+内容ID;提及关系多对多 | 内容、元数据与评论数量分开;多品牌测评不能算成某品牌商品评价 |
+| 政策 | policy_documents/policy_clauses | 文档ID;文档+条款 | 截至日期、状态、生效日、附件、SHA口径、原文/摘要/建议分开 |
+| 榜单与覆盖 | ranking_signals/coverage_observations | 来源记录+商品 | 商品详情页榜单标识不当完整Top100/400;可采页不等于历史评价全量 |
+| 待核对 | quality_issues | 实体+规则+问题 | 不删除源资料;已限定维度未知不能满足该条件,未限定维度未知不应使相关记录消失 |
+
+实体DDL见 `ops/schema.sql`,检索投影、组合索引和中文FTS5索引DDL见 `ops/retrieval.py` 的create_index;品牌别名与品类定义见 `taxonomy.json`。品牌先采用包内商品详情的明确属性;无属性时,仅采用标题开头的唯一已登记品牌,记录 `rule_title_prefix`。这不证明商品真伪,也不冒称人工核验。营销标题多品牌、品牌不明确、品类冲突全部保留待核对。字典之外的品牌以后补充规则和依据再重新构建版本,不能擅自归“其他品牌”。
+
+检索投影共21,108条,覆盖九类实体,保存类型化过滤字段、原文、归一化文本和文本指纹。中文按字与二字词生成FTS候选,再逐项核字面短语;字段绑定参数。品牌、品类、商品、店铺、政策、时间等有索引,避免Agent反复扫描原始文件。原文仍在实体和source中保留,不用生成摘要替换证据。每个代际绑定原ZIP、分类、DDL与导入/检索代码指纹;游标绑定代际和实际查询范围。
+
+## 二、平台、品牌、品类怎么保证不串
+
+示例需求“京东、康师傅、拌面”:请求为 `marketplace=jd AND brand=ksf AND category=banmian`,SQL参数绑定;索引以同样顺序建立。标题关键词只在此范围内附加,不能代替条件。天猫用已识别 `tmall`;淘宝接口族 `taobao` 不等于天猫,未证实渠道 `taobao_unverified` 单列。
+
+拌面与热干面、面皮、炒面、汤面、干脆面、挂面、调料分开。杯/桶/袋是包装形态,不当平行食用方式;“非油炸”是工艺/宣称,不能直接推出低脂或拌面。当前严格拌面类只收明确标题且无冲突的记录,宁可留待核对,不用搜索词命中猜商品归类。标题分类有局限,输出必须带方法,后续以规格/属性复核提高覆盖。
+
+search只填写用户实际限定的维度,不要求补齐三个维度或传all。明确限定的条件始终AND;未限定品牌时可返回品牌未知但品类符合的商品,未限定品类时可返回该品牌品类待定商品。未知字段的风险随结果显示。旧query三维模板仅兼容。零结果先同范围查别名、正文、关联及覆盖,不暗中换对象补数。
+
+评价精确关联 `platform_family+item_id` 的商品,不能按标题、店名、用户昵称或单独商品ID猜归属。search的analysis模式排除默认评价、身份待核、错配和规格冲突;当指定品类时还要求购买规格明确属于该品类。未指定品类时可保留规格不明的链接下评价,随结果标category_state。旧query的reviewScope=sku_confirmed/listing只作兼容,不能混写两套请求。一条评价不是一个已核实独立消费者;同文不同ID仍保留原记录,但另计去重文本数。
+
+## 三、来源与时效
+
+每条可引用记录回传实体ID、source_record_id、源文件/行号、记录哈希、文件哈希、ZIP哈希、原始URL与时间;可用 `source` 读取原始载荷、完整上游路径、接口/请求追踪信息。列表只给最多3条上游引用,避免大段元数据挤占Agent上下文。来源路径不使用原E盘绝对路径直接读取,按包内完整后缀唯一匹配;不按同名文件猜,找不到则标问题。
+
+`recordTime/source_time` 是上游快照时间,`collected_at/startedAt` 是请求/采集时间,`imported_at` 是本次入库时间。无时区保留原值、UTC列空;calendar日期仅指原文字面日期。search的sourceDate/reviewDate/publishedDate须选择实际口径,min/max均含端点;旧query的from/to为起点含、终点不含。未知时间不拿入库日补,不判当天/过期;可用exists审计缺失数量。`live` 只说明曾得到响应,不保证新鲜。
+
+政策声明SHA有三种来源:PDF字节、JSON文件字节、JSON中 `text` 的UTF-8字节;逐一记录 `hash_basis`,不能拿不同口径误判。政策状态截至原包2026-11-04,是采集者声明,不当今天法规有效性确认。摘要/表值转写不得冒充逐字引文;应用建议不是法规原话。
+
+## 四、Agent 流程
+
+1. 从问题识别目标、实际约束、需要的实体/关系、证据、时间与计数口径;先用contract读实际能力、字段/单位、字典、路线和代际。报告与项目另查业务库。
+2. 按任务选字段/正文/同义词/关联检索;facets定位可用范围,aggregate按实际范围分组,核effectiveQuery并用绑定范围/代际的游标查完。每页最多100条,不用临场抓取替代查询。
+3. get/source核关键条目、引文、上下文和出处,分别记录条数、去重文本数、商品数、有效/排除样本、日期与来源,不夸成行业全量或独立消费者数。同义词只帮助找候选,不证明负面观点或语义结论。
+4. 够用则复用。缺口只补具体平台、品牌、品类、规格/字段和时间。工具失败/无权限/未安装不当作零记录,更不能自动付费探测。
+5. 研究成果回业务库时遵循既有合同与查重,保存本库引用定位。既有 `workshop-search` 尚不支持完整三维筛选,不伪造 filters。
+
+## 五、本轮可确认与部署界面
+
+本地结构库已实际写入和校验,独立安装包包含数据库、只读Skill、查询脚本、结构定义与校验。正式数字人安装由运维001按随包步骤进行,王刚001按真实消息验条件和来源。无需更新生产云函数、Schema、静态文件或通讯录;现有身份/报告/项目规则保留。当前未连公开看板,也没有把本地库冒称生产平台已入库。
+
+研究底料对被授权实例开放,不发布匿名查询接口;目录0700、文件0600,属主为数字人运行用户。安装在当前授权实例,不自动复制到舰队其他Agent。未来资料更新先构建新代际,完整校验后再切换;不覆盖当前只读库,不把历史价格/来源修改成新观察。需要共享服务时再实现按身份授权的服务层;Parse生产类名映射需要单独发布,不能把设计表当已部署能力。

+ 33 - 0
docs/wecom-aibot-identity-handoff.md

@@ -0,0 +1,33 @@
+# 交给运维001:aibot 发送者身份接入
+
+本方案依据2026-11-02转交的只读核查报告,本地尚未取得或修改 /opt/hermes 源码。当前数字人仅运维001和王刚001。仓库提供接收逻辑、字段约定和离线测试,不包含已经在 Hermes 验证的适配器补丁。
+
+## 已知与未证实
+
+报告确认接入为智能机器人 aibot over WebSocket,适配器读取 body.from.userid,单聊无chatid时用userid兜底。它把userid填入username,模型因此看见机器ID。没有姓名/corpId的读取证据。查环境变量没有员工ID不能推出入站事件没有发送者。
+
+相同前缀不证明账号属于哪一种类型,也不证明跨bot稳定。不要将from.userid当成通讯录成员UserID;不要把群chatid映射为员工,也不要依据已有报告ownerName/name:Agent来认人。_User等库中的姓名不是当前发送者的认证依据。
+
+## 接入层应提供的内容
+
+从已经由连接器鉴别的当前入站事件提取senderId=body.from.userid、messageId=body.msgid、chatType=实际chattype、chatId=实际chatid(单聊可null),从本实例已订阅配置取得botId。使用ksf.wecom-aibot-context.v1结构,将字段以逐消息私有上下文或受控工具参数交付。Skill精确结构见references/identity.md。
+
+不要把姓名伪造为userid。建议在确认SessionSource契约和空值渲染测试后,缺姓名时不填username,保留userid;无证据时不要新增corpid/openuserid。更重要的是保留结构化发送者元数据供工具读取,不能只依赖模型提示中的“User: 某ID”文本。实际API参数名、钩子及生命周期须对照当前源码,不根据报告行号直接做盲补丁。
+
+上下文必须与当前消息、当前bot对应,文件放/opt/data下,0600,消息间隔离。不得写secret、正文、私钥、cookie;不得用全局环境变量传当前发送者。JSON格式和文件权限只能校验输入约束,不能提供来源证明;接入层负责来源可信性、防止用户正文冒充元数据,并防止并发/历史消息串身份。
+
+数字人自身档案account.accountId存实际订阅botid,account.platform=wecom,channel=wecom。员工账号中的identifierType=wecom_aibot_sender、scopeId=botid、accountId=from.userid;tenantId未知为null。botid不是corpId,也不是企微应用agentId。未经真实同人对照,不跨王刚001和运维001自动合并员工。
+
+## 本地已准备与待做
+
+Skill新增register-channel-employee命令,从私有上下文自动登记或复用员工键;无姓名生成“员工待绑定·摘要”,不要求员工填写账号。尚未实现管理员实名绑定/改名迁移;实名及跨bot关系需要后续受控核实,不改已有样例和历史归属。
+
+服务端准备了两函数增量:ksfStartWorkshopSession接受有作用域的aibot发送者账号并核对执行bot;ksfGetIngestContract增加channelIdentitySchema=ksf.wecom-aibot-sender.v1。缺能力或bot不匹配时停止创建会话。账号快照私有,公开看板沿用既有样式显示待绑定标签。
+
+请先在隔离副本中准备接入补丁和测试,返回:当前Hermes/插件版本及相关文件SHA-256、具体钩子与代码差异、上下文生成及销毁/失效方式、姓名缺失时的处理、回退方式。所需片段仅限入站处理、SessionSource及工具上下文接口,不提供订阅secret、完整配置或聊天正文。本轮不改客户配置、不重启、不触发客户容器,也不执行云函数--apply。
+
+## 联合验收
+
+先用离线夹具:同一sender同bot跨单聊/群聊得到同键;同群两个sender不同键;相同sender不同bot先不同键;缺sender、bot不匹配、过期/错消息上下文在写入前停;用户正文伪造元数据无效;无姓名仅显示待绑定,不要求员工答账号;原始ID/secret不进入模型公开输出和公开看板。
+
+然后在接入补丁、Skill和两函数分别上线后,用授权的新真实企微消息验证并取得新sessionId/artifactId;受控管理端独立回读,不能把离线夹具当真实企微证据。登记不等于员工登录、实名认证或正式报告确认,不开放额外权限。

+ 51 - 0
docs/wecom-current-employee-registration.md

@@ -0,0 +1,51 @@
+# 当前消息直接登记接口 · 隔离准备件
+
+本件交运维001做隔离合并和 Linux 测试,不是安装授权。王刚001继续等待。接口只登记本地员工档案,不创建会话、员工登录账号或业务记录;后续真实消息验收仍需单实例安装窗口。
+
+## 调用链与边界
+
+模型调用无参数工具 ksf_register_current_employee → Python handler 直接调用 identity_bridge.current_identity() → 读取维护人配置并核对 botId 指纹 → 私有 stdin 管道调用 Node 档案代码 → 返回脱敏的员工档案键及 Agent 档案键。
+
+身份字段不经过模型转述;模型不能通过工具参数替换 botId、senderId、messageId 或路径。查询工具 wecom_identity_current 可保留作诊断,但不用它的输出生成登记 JSON。
+
+内部 Node worker 复用 registerChannelParticipant,因此键算法、员工标签、幂等规则和账号快照与现有 Skill 一致。旧 register-channel-employee 的所有形式均已禁用;register-employee 也不接受 channel_context 或 aibot 账号。role=operations 不允许走课堂员工登记。
+
+保证范围:工具正常调用链不接受模型指定身份。私有 stdin 不是认证协议;文件权限、维护人配置与进程内上下文不能隔离任意同 UID 代码执行。自由终端若可改插件/档案,仍有相同 UID 的权限边界限制。本件不宣称抵御该能力,也不把服务端的 scope/来源字段一致性检查称为独立企微验真。
+
+## 合并位置(本轮只在隔离副本)
+
+1. integrations/wecom-identity/workshop_registration.py 对应平台包 plugins/platforms/wecom/workshop_registration.py。
+2. integrations/wecom-identity/__init__.py 是组合插件入口,替换 v3 的 plugin/wecom-identity/__init__.py;一次注册查询与登记两个工具。沿用 v3 的 plugin.yaml,但描述可补登记能力。
+3. Skill 新增 scripts/ksf-identity-bridge.mjs;其余更新按 Skill FILES.sha256 十项核对。scripts/install-workshop-skill.mjs 支持原工作坊、旧双槽、通用登记和已发 aibot 包的已知基线;保留凭据绑定与状态。不要在本轮执行安装器。
+4. 维护人配置将来放在 HERMES_HOME/plugins/wecom-identity/registration.json,0600、当前网关属主、路径不能含软链接。仅含 version、agentProfileKey、botIdSha256,示例 null 故意不可运行,不能原样安装。
+5. agentProfileKey 必须是维护人确认的 agent-account:完整摘要;对应档案须有真实 botId、channel/account.platform=wecom、role=workshop。名称“王刚001”由维护人确认关联,不能由聊天/包名推导。botIdSha256 使用真实 botId 原始 UTF-8、无盐、小写完整 SHA-256;不要写 secret。
+
+王刚001尚无可信稳定账号档案时,维护人先按核实的 bot 账号与名称建立 Agent 档案,再取返回键配置。不得凭模板填写或把运维001档案用于王刚001。当前未知实际宿主映射,准备件不写入任何真实配置或身份档案。
+
+## 接口契约
+
+工具名:ksf_register_current_employee。参数必须为空对象;任何非空对象或其他类型都返回 NO_ARGUMENTS_ALLOWED,不读取身份、不写档案。
+
+成功结果含 ok=true、operation=current-employee-registered、invocationId、changed、profile.key/name/identityBasis、agentProfileKey、needsNameBinding、currentMessageChecked=true;accountAuthenticated=false、displayNameAuthenticated=false、networkRequests=databaseWrites=0。无真实姓名时为员工待绑定标签。
+
+失败结果只给固定错误码;不输出原始异常、worker stderr、配置或账号。worker 报失败时不自动重试、不自动删已写档案,先保留现场核对。即使回执校验失败,也不能由失败回执推断零本地写入。
+
+Node 内部请求形状:version=ksf.current-employee-registration.v1、agentProfileKey、identity(原六字段)。worker 不接 argv 参数,stdin 上限16KiB;身份来源检查由进程内 handler 完成,worker 只检查结构、Agent 角色和 bot 作用域,不把自身单独当成可信自动登记命令。
+
+## 合并前先修 v3 的实例清理
+
+v3 sweep() 枚举所有 .json,instance.json 只在 adapter 初始化写一次;超过六小时后新消息触发 sweep,即删除实例登记,后续工具报 INSTANCE_REGISTRY_MISSING。
+
+建议仅清理文件名匹配完整 64 hex + .json 的消息文件,临时文件另按既定规则处理;实例文件单独存放或明确排除。测试把 instance.json 调旧、保留一条旧消息文件,再写入新消息,确认旧消息清掉而实例仍在,随后桥可读取身份。不用重启或每条消息重写实例来掩盖问题。
+
+实例文件0600也不能证明“不可被同 UID 改写”。先限定单个 HERMES_HOME 对应一个活动 aibot 适配器;未来多 bot 同目录需改实例作用域,不能继续使用共享 instance.json。
+
+## 隔离验证
+
+在包根,Node 检查:node --test test/workshop-identity.test.js。
+
+Linux Python 检查:将 KSF_ADAPTER_REVIEW_ROOT 指向解压的 v3/修订后补丁根目录,再运行 python3 test/workshop-registration.test.py。本测试用合成 ContextVar 与模拟原始文件读取,真实执行 Python→Node→临时档案,不需要服务凭据、不联网。它不替代 Hermes 真实 handler 的 ContextVar 继承或 POSIX 权限验收。
+
+补丁自己的五套 Linux 测试仍需在修订副本重跑;清理回归、同一份 ContextVar 映射下真正重叠线程、组合插件加载两个工具应另行覆盖。v3 现有线程测试在每次 start 后立即 join,并非并发重叠。请给当前 0.21.3 的 _VAR_MAP/_UNSET 定义、设置/清理与 register_tool/handler 调用的最小源码和指纹,用于核对替身是否等同实际接口,不需要配置原文。
+
+两边隔离测试与代码核对通过后再交单实例安装计划,安排真实消息验收与已暴露 secret 的受控轮换。服务端两个身份函数是否已升级仍需独立回读;本件没有部署它们,不将之前只读预检当上线。

+ 41 - 0
docs/wecom-tool-result-hotfix-20261102.md

@@ -0,0 +1,41 @@
+# 企微身份工具返回格式定向修复
+
+实际故障:Hermes工具框架只接受字符串或特定_multimodal字典,已安装的两个handler返回普通dict,工具调用后结果被框架拒收。此前测试仅调用handler并验证dict内容,没有覆盖真实返回值归一化;这是本次测试遗漏,不能把旧隔离测试通过当成实际工具契约通过。
+
+## 两文件改动
+
+- workshop_registration.py:现有处理逻辑保留为内部函数,唯一对外注册函数统一json.dumps(..., ensure_ascii=False),成功、参数拒绝、上下文失败、配置异常、worker失败及通用异常全部输出字符串。
+- identity_bridge.py:只将注册到工具表的_handler改为JSON字符串;current_identity内部函数继续返回dict,供登记handler直接核验。不能把内部函数一并改字符串,否则会破坏登记入口。
+- 查询工具的零参数约束同步落实在handler;内部受控调用和当前消息ContextVar来源不变。
+
+不修改registry.py/framework,不增加_multimodal伪信封,不改适配器、插件入口、配置、registration.json、Skill十文件、冻结r7或后台Function。
+
+## 先纠正副作用口径
+
+“模型没收到结果”不等于“没有执行”。框架返回值检查发生在handler执行之后,Node worker可能已经创建本地员工档案;也可能是错误dict被框架拒收。现阶段结论只能是“登记结果未能送达,档案状态待核实”。停止无意义重复调用,保留本地身份目录,不删档案、不换员工键、不造身份JSON恢复。
+
+修复后新的真实消息若changed=false可能正常,表示之前已经落盘;不要求第一次拿到回执必须changed=true。以同员工同bot的profile.key稳定、currentMessageChecked=true和受控归属核对作为通过判据。已知这类异常可能有副作用,后续任何写工具出现结果交付失败都应先回读状态,不能直接认定没写。
+
+## 运维执行步骤
+
+1. 停在身份登记验收,不继续业务写入/K轮。新包独立解压,校验FILES.sha256与HOTFIX.json两文件旧/新hash。旧v4包和其他包保持原件。
+2. 使用目标Python做语法检查。root运行python3 scripts/install-wecom-tool-result-hotfix.py为只读计划,目标固定/opt/hermes/plugins/platforms/wecom。只能识别HOTFIX.json声明旧版或本修复版,不修改指纹放行。
+3. root运行同一脚本 --apply --backup-dir /opt/data/已核实受控备份父目录/本次新目录。父目录须预先存在;备份目录必须不存在。脚本仅修改两个平台文件,备份旧内容、属主、权限,写前复核,原子替换并回读,失败按当前版本保护回退。不会自动重启或写身份状态。
+4. 重启前,用目标实际Python和hermes用户在独立临时目录跑随包test/workshop-registration.test.py。设置KSF_ADAPTER_REVIEW_ROOT为包内baseline,KSF_REQUIRE_REAL_TOOL_REGISTRY=1,目标/opt/hermes进入PYTHONPATH,让测试真正导入tools.registry并调用框架的返回值归一化方法;本测试只使用临时构造身份和Node档案,不发送企微消息,不读生产身份配置,不访问数据库。真实registry不可导入或接口不匹配就停并回报实际接口,不关闭开关冒充验证通过。
+5. 本包另有test/wecom-tool-result-install.test.py验证只读/安装/重复执行/失败回退/并发修改保留,KSF_TOOL_HOTFIX_BASELINE指包内baseline。运行其临时目录测试不会改生产文件。Skill路径与包内布局保持一致,Node须能找到。
+6. 静态/真实框架格式测试通过后,按已授权服务方式再重启一次:docker exec agent-node-u17879018108 /command/s6-svc -r /run/service/gateway-default。先记录当前PID,确认新PID、插件工具加载、企微订阅;失败停,不循环重启、不自动docker restart。三组此前已停止预览不重建,不动其它容器。
+7. 恢复后通知用户向王刚001发一条新的真实消息,调用ksf_register_current_employee({})。只回脱敏回执,不让模型调用查询工具再转述原身份。成功再用第二条新消息确认同键复用changed=false。归属核对通过后才继续原完整业务验收。
+
+## 真实框架测试命令示意
+
+以hermes用户执行,<解压绝对目录>替换为真实路径;HOME/HERMES_HOME=/opt/data。不要把实际身份/密钥写进环境变量。
+
+    PYTHONPATH=/opt/hermes KSF_ADAPTER_REVIEW_ROOT=<解压绝对目录>/baseline KSF_REQUIRE_REAL_TOOL_REGISTRY=1 /opt/hermes/.venv/bin/python3 <解压绝对目录>/test/workshop-registration.test.py
+
+本地9项登记与组合工具测试通过、3项安装器测试通过;本地没有Hermes真实registry,因此本地结果仅证明输出为str且可解析、原身份边界及Node复用行为保留,真实框架与真实企微仍须目标验证。不要再将“隔离加载注册2个工具”当“返回值已被真实框架接收”。
+
+## 回退与后续
+
+回退按此次备份只恢复这两文件,恢复前核当前仍为本次新hash;并发外部版本不覆盖。代码回退不删除已登记员工档案,也不恢复已暴露凭据。回退如需服务生效按受控恢复处理,不在失败后盲目多次重启。
+
+新的两文件及完整指纹留存到既定持久化重放材料中。仅重放原v4会恢复dict故障,容器重建时应先核基线→原v4→本返回格式修复→最后核新hash。原包清单不改,本补丁独立留档。

+ 49 - 0
docs/workshop-dedup-20261102.md

@@ -0,0 +1,49 @@
+# 工作坊采集资料查重升级
+
+经理的问题成立:新批次或换业务键原本仍可新增相同来源/实体。已有requestId幂等只解决原请求重放。本升级在现有Skill增加workshop-dedup,并在workshop-import自动预检,服务端实际提交再次在事务锁内检查。
+
+## 判断规则
+
+| 情况 | 行为 |
+|---|---|
+| 同一requestId、同一请求 | 原回执复用 |
+| 不同批次、相同owner/模式/来源类型/URL/平台与内容SHA | sourceId复用,保留首次来源元数据 |
+| 同源结构化资料完整字段相同 | objectId复用,operation=unchanged |
+| 原始平台记录真实上游ID相同,完整载荷及业务内容相同 | 可跨来源批次复用;不覆盖已有人工判断 |
+| 缺真实上游ID | 仅同源同索引同载荷复用,不将不同人的相同短评合并 |
+| 同名但规格、正文、价格、观察时间或原始载荷变化 | 不自动合并;不同业务键保留独立快照,同键冲突停止核查 |
+| 相似内容、不同来源URL、不同授权身份 | 不做语义合并/跨权限去重 |
+| 历史已有重复 | 不自动删库,不修引用,不回填伪造身份 |
+
+无sourceRef的基础分类等按完整字段比较;有sourceRef的普通实体限定同源。原始记录忽略批次runRef、recordIndex、collectedAt及服务端噪声状态,但原始载荷内变化仍保留。合同limits.maxReadScan超限明确失败,不把未查完说成无重复。暂未建立全库语义去重或任意并发写入防线:同表新版导入用数据库事务咨询锁串行,绕过这些函数的旧脚本/直接数据库写入不受保护。
+
+重复资料实体可复用,新的采集执行、操作回执和导入日志仍保留。因此数据库会因新资料和审计增长,这种增长合理;不能承诺完全不增长。原件上传发生在来源登记之前,文件字节已经上传的审计/文件对象不在此升级删除范围。
+
+## 交付与执行顺序(运维001)
+
+这是新增需求的独立补丁。旧v4与集成ZIP原件保持不变,不在旧清单上改hash。补丁含升级后十文件Skill和自己的清单;插件/适配器/身份登记工具不改,r7和credential-binding.json不改。
+
+1. 解压到独立目录,核外层FILES.sha256与内层Skill十文件;本包不得覆盖旧交付解压目录。读本说明和实际五函数manifest。
+2. 先按既定身份方案完成两个身份函数部署,再运行本包server/scripts/deploy-dedup.mjs只读预检。使用运维既有私有KSF_CONNECTION_CONFIG,不向聊天传配置。新版更新四个现有Function代码,并新增ksfCheckDuplicates一个Function。UNKNOWN_FUNCTION_VERSION/身份冲突停,不改manifest放行。
+3. 同一已授权部署任务运行node server/scripts/deploy-dedup.mjs --apply。备份、回读、/schemas不变校验由脚本执行;仅写Function代码/定义,无业务数据写入。回退校验当前仍为本版本;新建函数只有已知objectId且代码吻合才可移除,响应丢失需人工核对,不能盲删。
+4. 身份部署工具不要在查重补丁之后再次覆盖ksfGetIngestContract;它仍锁旧版目标指纹,覆盖会被拒绝,不能放宽旧工具。执行顺序固定“身份升级→查重升级”。新合同同时支持identitySchema、channelIdentitySchema与deduplication.schema。
+5. 目标Skill安装改用本包十文件和scripts/install-workshop-skill.mjs,hermes(uid10000)、HOME/HERMES_HOME=/opt/data;支持既有expectedWorkshop和已核验集成版本。本包安装器保留状态与凭据,运行后按既定流程备份/复制/回读FILES.sha256并核权限。不要再用旧集成ZIP把新Skill覆盖回去。必要重启仍并入同一次既定服务窗口。
+6. 真实消息登记与K轮完整链路用原已核验执行包继续,不改K键,不在运维身份冒跑,不因查重额外造身份。新workshop-import会检查查重合同,服务未升级则在登记来源前停。
+
+如原安装任务已经开始,先报告已执行步骤和实际版本,切勿一边覆盖一边让王刚跑导入。可在首次真实业务导入前完成这份补丁;已完成身份安装无需重做登记。只有任务尚未开始时才按完整合并顺序一次实施。
+
+## 查重验收(王刚001任务内执行,sample)
+
+K轮原任务不重放。在单独稳定的去重验收键下准备一份注明sample的原料资料;第一轮workshop-dedup→workshop-import保存实际sourceId/objectId,再使用同一原文/URL/结构化字段、不同requestId和externalKey重复导入。预期第二轮sourceOperation=unchanged、counts.created=0、counts.unchanged=1,sourceId/objectId相同。保存两层真实invocationId与回执。
+
+保持业务键和记录数据一致只换requestId的测试仅证明原有幂等;本轮必须换externalKey证明新跨批查重。改规格/原文/价格的测试使用另一个明确版本键,不覆盖先前对象;原料名称相同不能单独视为重复。测试记录正常保留,不自动删除。
+
+只读命令形状:
+
+    node /opt/data/skills/ksf-workshop-runtime/scripts/ksf-secure.mjs workshop-dedup --input /绝对路径/任务.json --request-id 本轮稳定查询键
+
+任务JSON与workshop-import同source/records形状。复用、未命中、依赖待解析分别汇报,不把预检当完整校验或真正入库。公开页面验收及完整S1–S5要求仍适用。
+
+## 本地验证与实际状态
+
+测试覆盖跨requestId来源复用、URL/模式/owner隔离、跨业务键复用、原始上游ID跨批次、同短评不同人、数据变化保留、人工噪声判断保留、只读预检无写、扫描上限、服务未升级停、独立部署只读/回退/创建响应丢失,以及完整S1–S5回归。此文和交付不是远端安装证明;生产验收需运维与王刚实际回执。

+ 53 - 0
docs/workshop-dedup-acceptance-20261102.md

@@ -0,0 +1,53 @@
+# 原料跨批次查重验收
+
+任务键固定为 KSF-查重实测-20261102-01。这是独立验收,不重跑已成功的 K 轮。
+
+## 范围
+
+独立执行器 scripts/workshop-dedup-acceptance.mjs,只调用现有 Skill 加密入口。context-dedup.example.json 是三字段模板,agentProfileKey=null 故意不可执行,由维护人填写已经核实的当前 Agent 档案键。不登记新身份,不包含员工账号或密钥。
+
+本次不升级平台、Skill 或 Function,不重启服务。只新增执行器及私有输入/日志。实际运行会产生 sample 来源/原料、两批导入及审计记录;不是零数据库写入。重复业务实体会复用,导入日志仍正常增长。
+
+## 固定输入与变化规则
+
+样例原文、来源标题、sourceKind=dataset、dataMode=sample,以及原料业务字段全部由脚本固定。原文声明为人工构造,无真实平台、供应商或检测依据。不添加虚假 URL。
+
+两轮唯一变化:第一轮 requestId=任务键:batch-a,externalKey=任务键:material:a;第二轮 requestId=任务键:batch-b,externalKey=任务键:material:b。没有额外的 batch 字段,也不把批次写进原文或业务内容。
+
+## 顺序与成功判据
+
+1. 只读合同确认 ksf.ingest-dedup.v1 与 ksfCheckDuplicates。
+2. 第一轮只读查重,来源尚无精确命中才继续;已有历史则停止核对。
+3. 第一轮导入,应为 sourceOperation=created、created=1、unchanged=0,保留 sourceId/materialId/jobId。
+4. 第二轮只读查重,必须命中同一 sourceId/materialId。
+5. 第二轮导入,应为 sourceOperation=unchanged、created=0、unchanged=1,两轮 sourceId/materialId 完全相同,jobId 不同。
+6. 精确回读原料,确认全部样例业务字段及 sourceRef 一致。
+
+成功 operation=sample-material-dedup-verified。接口真实字段为 unchanged;summary 中 secondReused=1 是便于阅读的同义说明,并非服务端另有 reused 字段。
+
+## 路径与权限
+
+- 执行器 /opt/data/ksf-executor-20261102/workshop-dedup-acceptance.mjs,root 安装、0644、hermes 可读。
+- 输入 /opt/data/ksf-workshop-private/context-dedup-01.json,hermes 属主、0600。
+- 状态父目录 /opt/data/ksf-workshop-private/dedup,hermes 属主、0700。
+- 目标状态目录 /opt/data/ksf-workshop-private/dedup/KSF-dedup-20261102-01 必须不存在。
+
+执行器以 hermes UID10000 运行,自建0700目录;拒绝软链及不符合权限的输入/父目录。禁止删除已有目录来重跑。不递归 chown 数据卷。
+
+维护人可从已核实的 context-K.json 读取 agentProfileKey,在内存中仅取这个字段,结合模板的 version/taskKey 生成新输入;不复制 conversationKey/participantProfileKey/完整旧上下文,不改 context-K.json。输入不构成员工登记来源。
+
+命令由王刚001在一条新的真实企微消息授权任务中运行,环境 HOME=/opt/data:
+
+    node /opt/data/ksf-executor-20261102/workshop-dedup-acceptance.mjs --input /opt/data/ksf-workshop-private/context-dedup-01.json --state-dir /opt/data/ksf-workshop-private/dedup/KSF-dedup-20261102-01 --execute
+
+不由运维 Agent 冒用工作坊角色执行。
+
+## 失败与留存
+
+每一步先记 in-flight,再调用现有 ksf-secure.mjs。完整输入、外层/内层回执、传输状态、journal、summary 均留在私有目录,文件0600。输出不含密钥、员工账号、会话键。
+
+失败即停,不重试、不换键、不删样例记录、不清目录。返回错误不代表没有入库;超时/响应丢失时也可能已写入,以原 requestId、sourceId、materialId、jobId 与私有回执核查。
+
+只回报 ok/operation/taskKey、两轮 sourceId/materialId/jobId、created/unchanged、两个 import invocationId 及状态目录。无需粘贴完整 journal 或输入上下文。
+
+本地验证包含:真实业务处理代码在内存数据库中跨批次复用、第二批响应丢失时保留现场、预检不一致阻止第二次写入、已有历史停止。工作坊测试75/75通过;这些是本地验证,尚未代替目标实例的真实消息实测。

+ 51 - 0
docs/workshop-identity.md

@@ -0,0 +1,51 @@
+# 工作坊身份核查与字段约定
+
+2026-11-02。本次依据受控管理连接取得的只读证据 `work/identity-audit-20261101.json`(2026-11-01T15:57:36Z),以及现有 Skill、服务代码对照。没有为核查创建员工、Agent 或业务样例。
+
+## 数据库实际现状
+
+- `_User` 返回 0 条。不存在独立员工/Agent 注册表;`meta.ownerId` 是写入所用服务身份,不是企微员工或机器人账号。
+- KfSession 12 条、KfArtifact 32 条、KfProject 11 条、KfOpportunity 10 条。旧记录中的“康师傅产品创新助手”“品类调研 Agent”等仅是历史记录值,不能据此认为这些机器人现已配置。
+- 工作坊联调会话 mt3CR2Y3HZ 的员工为“工作坊联调(非学员)”,Agent 为“王刚001(联调模拟)”,userRef 为空,agentKey 为 `name:王刚001(联调模拟)`,来源为 name_only。没有真实平台账号标识。
+- 尚无彭智康与真实王刚001的 K 轮会话及成果,不能把技术联调记录当成本次企微实操。
+
+| 对象 | 已有归属字段 | 本次缺项 |
+|---|---|---|
+| KfSession | studentName、userRef、groupId、workshopKey;meta.attribution 内的 participantName/Key、identityBasis、agentName/Key、agentKeyOrigin、conversationKey、channel | 私有员工/Agent 平台账号及 Agent 自身登记机制 |
+| KfArtifact / KfProject | ownerName、uploadedByKey、agentName、agentId、ownerSession、sessionRef、uploadMethod;新工作坊记录另带归属快照 | 完整账号只能在补扩展后的私有快照中保存 |
+| KfOpportunity | uploadedBy、uploadedByKey、agentName、agentId、sourceSession、sessionRef | 通过 sessionRef 追溯账号资料 |
+| KfConcept / KfFormulaItem / KfStageEvent | projectRef / conceptRef / sessionRef | 沿关联追溯,不重复建立假账号 |
+
+## 补充设计与本地实现
+
+复用现有 meta、producer 和引用字段,无需改变业务表结构、建立登录账号或改看板样式。
+
+所有新员工和新数字人都先登记独立档案;支持任意姓名和实例数量。Agent 自身档案存 name/key/role/identityBasis/runtimePlatform/channel/collectedAt/account。王刚001=workshop、运维001=operations 是已知实例用途,不是固定槽位。账号字段固定为 platform、tenantId、accountId、accountName、runtimeAgentId、wecomAppAgentId;未知 null,不从显示名推导账号。企微 corpId、企微应用 agentId、运行时 Agent ID 分开保存。人工登记生成持久 UUID;运行配置或可信渠道上下文必须提供真实稳定标识,生成平台与编号的摘要键,显示名与采集时间不进入稳定键。登记不表示平台认证,agentAccountVerified 始终为 false。
+
+员工保存 name/key/identityBasis,account 包含 platform、tenantId、accountId、accountName、employeeNumber,未知字段 null。企微 userId 填 accountId;participant.userId 仅可引用本应用已有 `_User.objectId`。没有发送者 ID 时登记参与者 UUID,不拿群会话 ID 代表所有员工。同名返回 needsReview 与候选;明确复用才填 reuseKey,确认不同人才用 confirmNew 新建。稳定账号相同且资料一致时自动复用,资料冲突停止。
+
+档案分别保存于 /opt/data/ksf-workshop-state/agents/ 和 participants/,文件名取完整档案键后缀;多人、多个同角色 Agent 共存。`workshop-start` 必须引用 participant.profileKey,并传 --agent-profile <Agent完整档案键>,自动注入两份登记资料,缺档案在创建前停止。后续阶段写入检查 session 与自身 Agent 一致。冲突停止,不覆盖旧档案或换业务键。仅 role=workshop 能执行课堂写命令;角色本身不是服务权限。旧双槽文件可验证后 adopt-agent 复制进新目录,原档案保留。
+
+本地登记不写远端员工表、也不创建 `_User`;会话成功时写入私有归属快照。不同容器不能自动发现本地 UUID,需要受控核对和复用同一档案,不能按姓名跨容器合并。数字人档案也不是可供其他实例随意冒用的认证凭据,必须与当前执行者可信配置核对。
+
+自身配置的实际路径/接口需由数字人在当前运行环境核实;本地程序没有猜测 Hermes 配置路径。数字人首次提取白名单形成非密钥摘要,register 保存;后续 check 可对照当前配置摘要,零写入、零网络。业务入口自动带入的是已登记档案;当前没有可信配置可读时,必须如实说明仅复用登记资料。
+
+服务端把账号资料保存在 KfSession.meta.attribution,报告、来源、项目继承同一快照,机会卡沿 sessionRef 追溯。通用资料导入的 KfImportJob.producer.agent 保留执行者。密码、服务令牌、私钥和 cookie 不属于账号资料,输入白名单不接受它们。
+
+公开看板继续只返回所需归属名称/键。CLI 的 search/read/project-status 输出也移除账号、私密会话键和服务 ownerId;账号实际落库核验由受控管理端完成。业务原文/报告中不要复制账号信息。
+
+完整操作说明在 `skills/ksf-workshop-runtime/references/identity.md`;入口为 ksf-identity.mjs。身份登记不改变材料的 dataMode:构造待查清单仍使用 sample,save 继承 session 模式。新增 workshop-read 按返回编号读取,不误用 search 的 sessionId 过滤。
+
+## 生效状态与上线顺序
+
+2026-11-02补充:据运维001转交的只读核查,真实接入是aibot WebSocket,body.from.userid已存在;前面“没有发送者ID”的判断应收窄为此前未正确读取。此ID不自动视为通讯录成员UserID,企业及姓名仍未知。补充接收字段identifierType=wecom_aibot_sender、scopeId=botid;稳定归属以botid+from.userid为准,tenantId未知为null,不用chatid或前缀推断身份。无姓名时可自动登记“员工待绑定·摘要”并继续课堂,无需员工回答账号;实名及跨机器人合并仍须管理员核对。
+
+Skill已在本地增加register-channel-employee,两个待部署函数增加作用域校验及channelIdentitySchema=ksf.wecom-aibot-sender.v1。可信逐消息上下文需要运维实现适配器/hook交付,本仓库没有该远端源码,未编造适配器补丁,也未更改网关、配置或进程。具体接入契约与待做工作见docs/wecom-aibot-identity-handoff.md;本地夹具不构成真实企微来源或实名认证证明。
+
+本次为本地实现和测试,尚未把身份扩展部署到服务器,尚未更新数字人容器,也未填入无法核实的真实账号。现有公开看板和已上线九文件保持原状。
+
+最新aibot接收版的验证结果记录在 `work/aibot-identity-validation-20261102.json`,覆盖原通用登记、作用域发送者自动登记、缺档案阻断、归属快照与脱敏,并做I/J、旧双槽及通用登记包的隔离升级。它们是本地验证,不代表生产库、企微接入或数字人容器已升级;此前交付包保留,不覆盖旧附件。
+
+下一步可先安装新版 Skill 并分别登记自身档案;仅本地写入,零网络、零业务数据。运维001完成两函数发布预检后,正式升级 `ksfStartWorkshopSession` 和 `ksfGetIngestContract`;固定基线与函数对象 ID,未知版本停止,备份和失败回退只涉及两个函数代码。管理机已完成一次真实线上只读预检,两项均匹配已知基线,尚未执行 apply。
+
+随后只读合同应出现 workshop.identitySchema=ksf.agent-self.v1,再用现有 K 轮稳定业务键让王刚001执行一次员工会话与 S1 待查清单保存,管理端回读归属、账号字段及 sample 标记,再确认看板显示。不要重新跑已有技术样例或伪造企微账号。发布工具默认命令为 `node server/scripts/deploy-identity.mjs`,正式执行才加 --apply。

+ 113 - 0
docs/workshop-integration.md

@@ -0,0 +1,113 @@
+# 工作坊链路接入(2026-11-01)
+
+本次调整数据接口、字段适配与 Skill,并在成员页复用原有表格列出逐项目进展;没有修改 CSS、组件样式或布局设计。看板公开查看,不增加登录。服务写入由已有受限令牌授权,员工/Agent 归属是业务记录,不冒充认证账号。
+
+## 实际数据库信息
+
+本次查库:`_User` 账号表为空;11 条旧 Session 有 studentName / groupId / stage 等字段。26 条旧成果有 ownerName、ownerSession、uploadedByKey、agentName、agentId、uploadMethod、uploadedAt;9 张旧机会卡有类似信息。
+
+现存 Agent 名称有“康师傅产品创新助手”“品类调研 Agent”“概念设计 Agent”;未发现“王刚001”的实际业务记录。因此新上传必须从当前对话上下文取实际 Agent 名称,不能把这些旧默认值当作所有机器人的身份。
+
+## 数据约定
+
+- `KfSession.meta.attribution`:发送者稳定键的工作坊内摘要、显示名、身份来源、Agent 实际名称/标识、会话、渠道;真实 userRef 有则引用,无则不创建账号。
+- `KfArtifact`:报告原文、看板 report 章节结构、阶段、工作坊、小组、员工与 Agent、来源、待补项。课堂成果为 `dashboardState=workshop`,不伪造员工确认;正式报告确认流程保留。
+- `KfOpportunity`:独立机会对象,关联报告及会话,不仅保存报告里的一个段落。
+- `KfProject`:由已选机会建立,关联工作坊、小组/负责人、当前阶段与方案版本。这里的“立项”是课堂项目,不代表康师傅正式审批。
+- `KfStageEvent`:项目创建、成果提交、阶段推进、卡住、恢复、方案保存、课堂完成,事务内连续记录。
+- `KfConcept`、`KfFormulaItem`:方案版本、规格包装、买点、工艺路线与原料用量;未知项保留待验证。真实资料通过既有结构化导入函数入库,来源与模式保持一致。
+
+msgCount 只在渠道确实提供消息数时保存,不把任务调用次数当消息数。不同姓名/稳定键/小组不串项目;同名学员的展示附加组号或身份摘要以作区分。
+
+## 公开读取
+
+新增 `ksfGetPublicBoard` 无需身份登录,返回当前看板所需的六类数据并分页。服务器明确挑选可展示字段,不返回密钥、私密会话键或整份来源 rawPayload。旧展示数据继续保留;新课堂成果有说明;一般未发布正式报告和仅导入的私密原文不会自动公开。
+
+客户端在原有样式与页面中读取该投影,兼容 stageCode / projectRef,修复新数据阶段为空、项目关联丢失的问题。显式 blocked 状态进入现有“卡住”区。仍保留原有页面评分/改状态行为,本轮没有扩大或收紧旧表权限。
+
+成员页的项目进展表显示每个课堂项目的负责人、当前阶段、成果数、方案是否保存、最后活动与状态;项目名可打开其最新阶段报告。继续沿用既有表格和手机布局,没有重做看板视觉。
+
+## 当前交付与验收
+
+- `server/`:已部署云函数的源码、构建及本次增量部署脚本。仅发布 8 个目标函数,无 Schema、权限或凭据修改。核心正式报告确认与发布代码不变。
+- `skills/ksf-workshop-runtime/`:新版技能。保留原业务入口与冻结锁;新增 workshop 命令。现有 credential-binding.json 不随包分发,安装时原样保留。
+- `scripts/install-workshop-skill.mjs`:数字人容器中升级,先核验全部文件,再备份与写入,最后更新 SKILL.md。不改网关,不重启。
+- `scripts/verify-workshop-live.mjs`:管理机使用相同 Skill 模块的远端技术联调;全部内容明确 sample,不是真实员工对话、消费者采集或企业产品立项。
+- `scripts/verify-workshop-readback.mjs`:只读核查上述已有样例编号,不重放写入。
+- `scripts/verify-workshop-browser.mjs`:实际浏览器打开工作台/成员/阶段/报告,确认公开读取、员工/Agent、正文与手机视口。
+
+安装完成后,还需从新的企微消息真实执行一次“登记实际身份→保存阶段成果→回读”,才能说容器已使用新版 Skill。不要把管理机联调或磁盘安装当成企微调用证据。
+
+## 操作
+
+```sh
+npm test
+npm run test:workshop
+npm run build:workshop
+node server/scripts/deploy-workshop.mjs           # 只读生成部署计划
+node server/scripts/deploy-workshop.mjs --apply   # 增量部署八个函数
+node scripts/verify-workshop-live.mjs             # 使用固定键的 sample 技术联调
+node scripts/verify-workshop-readback.mjs         # 后续只读核查,不重复写入
+```
+
+管理连接只在部署脚本读取受控配置;数字人只用已有加密受限令牌。令牌有效期仍为 2026-11-06 18:42(北京时间),本轮没有自动续期。
+
+前端需按现有站点发布流程上线,代码通过浏览器验证与正式站点是否已发布须分别记录。当前工作不能代替真实面类资料准备、真实学员彩排或 30–40 人并发验收。
+
+截至本次交付:本地 88 项测试通过;浏览器 22 项检查通过,覆盖匿名读取、逐项目阶段/负责人/完成状态、桌面和 390px 手机阅读。八个云函数已部署回读且 Schema/权限保持原样。管理机样例项目 `Z-IEjZg0qZ`、方案 `NSM6FzDsiX`、报告 `5u4xzJW5DY` 已独立回读,所有成果仍为 sample / workshop,未正式发布报告。
+
+数字人升级包 `ksf-workshop-skill-20261101.zip` 和看板数据发布包 `ksf-board-data-20261101.zip` 由 `scripts/package-workshop.mjs <绝对交付目录>` 生成。用户确认前端由王刚001或平台后台发布;本轮未直接推 Git、未发布前端、未安装 Hermes 容器。容器安装器在隔离目录验证过首次升级、重复安装、未知修改拒绝、坏包拒绝和加密绑定保留。
+
+## 11 月 1 日数字人安装回报核对
+
+用户转发的 Hermes 原始输出显示:技能已更新四项,顶层 11/11、技能 7/7 校验通过;skills_list / skillview 已重新读取。I 轮 status 成功,J 轮 workshop-search 由新 `ksf-workshop.mjs` 执行,实际返回既有样例项目 `Z-IEjZg0qZ`。整包、wrapper、新旧入口的四个 SHA-256 均与本机交付件一致。此处依据转发输出验收,不表示 Codex 直接登录容器检查。
+
+本阶段已具备“数字人安装并调用新入口进行只读服务查询”的证据;尚无升级后真实员工对话生成并保存阶段成果的证据。凭据配置未改变,status/search 的加密包装器实际进行了内存解密与子进程注入,不能称为“完全未使用凭据”。
+
+同期独立读取线上前端:board-service.js 仍未使用 loadPublicBoard,members 尚无逐项目进展,新 workshop-data.js 地址返回 HTML 页面而非 JavaScript。HTTP 200 不能作为该模块已发布的证据。下一步由王刚001或平台后台合并发布第二包,再从新的真实企微消息验收员工/Agent 归属、阶段成果入库与线上显示。核对结果保存在本机 work/hermes-install-IJ-review-20261101.json 和 work/online-frontend-IJ-20261101.json。
+
+## 生产发布独立验收(2026-11-01 23:29,北京时间)
+
+运维 bot 回报已向核实的静态站点目录发布固定提交 d14d02fd358c6c5dedfabc96c53fb29add8bd021。本机随后独立读取生产域名,九个运行文件的完整 SHA-256 全部匹配发布清单,Content-Type 均为 JavaScript;首页仍为原有 1943 字节,SHA-256 与发布前一致。服务端目录、进程与配置情况来自运维 bot 的回报,本机未直接登录源站核查。
+
+使用实际浏览器访问生产域名,22 项检查全部通过:工作台、成员、阶段和样例报告正常加载,项目负责人、S5 与课堂完成状态可见;样例正文和 Agent 名称可见;成员页和报告页 390px 视口无横向溢出;读取走公开云函数,没有直接扫描 Parse 类。该次验收没有业务写入,也不代表新企微消息已经提交过阶段成果。
+
+证据保存在本机 work/workshop-production-verification.json 和 work/workshop-browser.json。剩余第一轮验收是由真实参与者在新的企微消息中登记当前员工与 Agent、提交阶段成果,并用新取得编号独立回读数据库和生产看板;此后继续机会卡、项目和方案的真实对话演练。
+
+## 11 月 1 日管理机补推前端代码
+
+数字人容器报告本地提交 `1f7c028bc075ece78b33b1b5f6d1cb109fd336f4` 因缺 Git 凭据未能推送。管理机使用原有 Git 凭据管理器中的身份,经 Gogs API 确认写权限后,在独立 worktree 提交同一批九个运行文件及 `.gitignore` 精确例外规则,并成功推送到远端 main:`d14d02fd358c6c5dedfabc96c53fb29add8bd021`。没有传递凭据给 Hermes,没有写入凭据文件,没有推送本机 server/skills 等其他未提交开发文件。
+
+主工作区 main 已对齐远端新提交,核对原有 608 个工作文件内容均未改变,其余开发文件保留。容器的旧本地提交无需重复推送;站点发布使用远端 main 的 `d14d02f`,需要同步容器源码时使用新 worktree,避免覆盖原工作区及备份。
+
+推送后独立核查线上九个运行文件:原有 vendor 依赖一致,其余八个业务文件仍未更新;新 workshop-data.js 地址依旧返回 HTML。现有身份无仓库 hook 管理读取权限(403),未取得可用构建状态或站点发布入口。仓库 deploy.sh 是依赖 OBS_BUCKET/obsutil 的部署模板,本机没有对应配置,不能据此猜测实际生产路径。已向用户询问实际平台项目页或部署脚本/目标目录;推送成功尚不等于站点上线。
+
+本机证据:work/frontend-push-result-20261101.json、work/frontend-online-after-push-20261101.json、work/frontend-deploy-status-20261101.json。凭据只用于目标服务认证,不在这些证据内保存。
+
+## 11 月 1 日发布脚本与线上前缀复核
+
+已在干净的独立 worktree 执行 `bash scripts/deploy.sh prod --check-only`,退出码 0。静态体检、core 单测、本地模块图均通过;脚本在上传步骤前退出,没有调用 OBS。生成的临时 index.html / prefix 文件已恢复到本次提交,未影响主工作区。
+
+实际线上 `/` 的 HTML 为 1943 字节,`base href=/`,入口 `/src/app.js`,importmap 均引用 `/lib/vendor/...`。`/kangshifu/` 返回同一根首页,而 `/kangshifu/src/services/board-service.js` 返回同一 HTML,不是 JavaScript;根路径 `/src/services/board-service.js` 才返回真实 JS。故脚本的 prod 默认 `/kangshifu/` 只证明模板的配置,不足以证明当前生产对象前缀。域名根路径仍可能通过 CDN originPath 映射到桶内某个目录,该映射需要真实配置核实,不能从 URL 推断。
+
+响应头 ETag `6ae57be3-797` 的前半段可解码为 `2026-10-31T05:25:55.000Z`,后半段 `0x797=1943`,分别吻合 Last-Modified 和 Content-Length。这符合 Nginx 静态文件 ETag 的时间/大小格式,不能证明源站为 OBS 或识别桶名。当前缺少的是生产配置记录中的源站类型、源站地址、回源目录与发布授权方式;若确认使用 OBS,再核桶名、端点/区域和 CDN originPath。没有要求用户在聊天传 AK/SK。
+
+Git 已推送、prod 模板本地体检已通过,实际站点发布仍未执行。不要在尚未核对生产源站配置时直接运行完整 prod 脚本;该脚本会重写首页前缀并先删后传首页。
+
+## 九文件定向发布工具(备用,未执行生产发布)
+
+新增 `scripts/publish-workshop-files.mjs` 与 `deploy/workshop-files-20261101.json`。白名单固定为本次提交的九个运行文件,已包含新增 workshop-data.js;不包含 index.html、.gitignore、服务端或技能。默认对维护人确认的文件系统目录只读预检,`--apply` 才实际写入;不猜源站、不联网、不接受凭据、不刷新 CDN。对象存储需要对应的上传方式,不能拿该目录工具当 OBS 客户端。
+
+预检核对交付内容、目标旧版/新版、首页指纹、真实目录和软链接。实际写入先在站点目录外备份,再逐文件原子替换与回读;最后再次确认首页字节未变。失败尝试回滚,回滚遇外部改动则保留该版本并报告未完全恢复。文件系统核验与域名最终验收分别报告。
+
+新增四项发布测试通过。另以实际交付九文件、Git 原基线和独立读取的首页,在隔离 work 目录真实运行打包后的 CLI 预检及 apply,两次退出码均为 0,九文件一致且首页未变。此演练不在生产源站执行,不构成线上发布证据。
+
+交付包 `ksf-board-delta-publisher-20261101.zip`:46,297 字节,SHA-256 `6f5f7c168e22ff827e563fb4778559f9b94f7d14967883774da86fdb45c2f6e6`,12 项内容清单均核验通过,无首页或凭据。使用前仍需维护人确认实际源站类型/目录或提供现有发布方式。
+
+## 数字人审读回报与行尾差异复核
+
+用户转发回报显示定向发布包 12/12 校验、源码审读、独立线上首页指纹核对均通过,未执行生产预检或 apply。按用户安排,部署及权限协调暂缓,工具作为备用。
+
+回报中七个运行文件原始 SHA-256 不同,不能据此判断“本地是初版、d14d02f 是代码最终版”或宣布本地提交作废。已独立解读两个原始交付 ZIP:最初看板发布包为七文件 CRLF,Git 提交提取的定向包为 LF;其余二文件字节相同。九个运行文件统一行尾后全部逐字节一致,回报中的七个本地哈希前缀也均对应最初交付件。此结论比较的是两份交付件,没有直接检查容器 1f7c028 的完整提交对象。证据为 work/board-payload-line-endings-20261101.json。
+
+首页指纹一致能识别内容副本,不能单独证明目标目录正在被生产域名使用;仍需维护人确认真实站点映射及执行环境能访问对应目录。当前线上更新与真实企微阶段成果保存后的页面验收尚待后续完成。

+ 94 - 0
docs/workshop-real-message-progress-20261102.md

@@ -0,0 +1,94 @@
+# 真实企微工作坊验收进展
+
+初始记录2026年11月2日14:10(北京时间);后续进展按下文时间追加。消息与容器执行结果以用户转发回执为依据;K轮业务记录由本机独立只读查询。
+
+## 已通过:真实身份登记与复用
+
+| 回合 | invocationId | ok | changed | currentMessageChecked |
+|---|---|---|---|---|
+| 第一条成功回执 | 641fecfb-5d6a-4480-bc0b-5dd6a496632e | true | false | true |
+| 第二条新消息 | d00b17fd-eacc-41dd-b471-475517d8cb19 | true | false | true |
+
+两条回执员工profile.key与agentProfileKey相同,复用既有档案;此前调用在返回格式异常前已落盘,因此第一条成功回执changed=false符合预期。两次invocationId不同,表示独立工具调用。needsNameBinding=true,未做账号或真实姓名认证;待绑定显示不阻塞sample流程,不能将其当真人姓名已确认。原始账号未在此文记录。
+
+已通过的是用户转发两条实际工具回执;私有档案与本条入站发送者/bot作用域的独立核对仍由目标维护人完成。networkRequests/databaseWrites=0仅指本地登记工具,不代表后续业务验收不写库。
+
+## K轮只读预检
+
+2026-11-02T06:10:30.382Z按workshopKey=KSF-企微实测-20261101-K查询生产KfSession/KfProject/KfArtifact,分别返回0/0/0。本次businessWrites=0,未创建工作坊记录。查询结果见work/K-history-after-real-registration-20261102.json。
+
+这不能替代目标本地journal、历史回执及在途请求检查。维护人核实这些也无已执行/在途记录后,才运行既定完整链路执行器;存在部分结果则保留原键/编号核查,不自动重放或换键。
+
+## 下一步
+
+1. 运维001独立核对员工私有归属与王刚001维护人映射,只报匹配/不匹配;由实际档案键和可信当前会话键以hermes写0600的context.json,保留身份状态。
+2. 王刚001在新的真实消息任务中执行原完整链路包:sample资料导入、S1/S2、机会卡检索选择、项目创建、S3/S4/S5、卡住/恢复、方案与原料/工艺引用、课堂完成及回读。保持原K业务键,失败停止,不删记录,不把样例当市场结论。
+3. 运维进行私有归属和匿名浏览器独立验收,回真实编号和报告链接。采集查重另用明确的独立验收键重复同份资料而换externalKey,确认复用;不重跑K轮证明查重。
+
+完整工作坊尚未验收通过,真实市场资料仍待补。本文件是进展记录,不是替运维/王刚执行或发送指令的证明。
+
+## 14:22更新:K轮样例业务与页面验收通过
+
+用户转发王刚001实际回执:一次执行EXIT=0,operation=full-workshop-api-chain-verified,S5/completed,未重试,保持原K业务键。该执行回执browserVerified=false;后续浏览器验收是独立步骤,不能回改原回执。
+
+本机已独立只读回读生产记录:session=2InAdyzimm、opportunity=rFRWpVPnjk、project=U7x82sQQtE、concept=JpVxAIJ8Fc、最终artifact=f7mUZET0WK。六份会话成果存在,其中三份归属于项目;10条项目事件,1条配料关系。分类kvhwLIEYiq、产品8rot-C7B28、原料YNivtOrBiA、工艺RaxsJKEUS7均真实存在并为sample,方案引用一致;员工/Agent私有归属一致,公开投影不含原始账号/会话键。本机回读businessWrites=0。
+
+随后使用独立无登录浏览器打开生产/、/pool、/members、/stages、/report、/report/f7mUZET0WK,穿透Shadow DOM验证正文;目标项目行显示待绑定员工、S5和课堂完成,报告显示王刚001、sample声明及待验证清单。35项检查通过,无运行异常,六条页面390px均无横向溢出,读取不携服务令牌。截图及独立证据见work/workshop-K-readback-20261102.json、work/workshop-K-browser-20261102.json、work/workshop-K-report-desktop.png、work/workshop-K-members-mobile.png。
+
+结论:真实企微身份→王刚001执行→S1–S5样例成果→项目/方案关联→生产看板展示的样例链路已打通。当前不是实际平台资料采集成功、真实市场验证通过或正式报告发布。独立重复采集验收尚未完成,不以一次新资料导入成功替代查重复用测试;下一步让王刚001按已交查重说明用独立sample验收键、同资料不同批次/externalKey验证第二次created=0、unchanged=1、sourceId/objectId相同,不重放K轮。真实业务资料、姓名维护绑定与凭据轮换分别后续跟进。
+
+## 14:41更新:独立查重执行器已备妥,待目标执行
+
+王刚001确认缺专用执行器,本机已补 scripts/workshop-dedup-acceptance.mjs,任务键固定 KSF-查重实测-20261102-01。先只读查重,再两轮导入并比较来源/原料编号,第二轮只改变requestId和externalKey。服务端计数沿用真实字段unchanged,summary同时解释为reused。已有历史、预检编号不一致或响应未知均停止,保留原目录/业务键,不重跑K轮。
+
+本地工作坊测试75/75通过,覆盖专用查重成功、响应丢失后保留现场、预检不一致禁止第二次写入。独立生产只读查询本样例来源正文hash、原料精确名称、导入jobKey任务键,命中均0,未执行业务写入;证据 work/dedup-acceptance-history-readback-20261102.json。该只读查询不能代替目标最后一次在途检查。
+
+交付 work/delivery-by-recipient-20261102/ksf-dedup-acceptance-20261102.zip,9395B,sha256=da2ccd08c5ef9ade956c1ef234e7c2fe351d17db1c017e37904c02af0b46f1c1,ZIP完整,FILES.sha256五项一致。含执行器、null档案键输入模板、操作说明与两份接收者说明,不含私有身份上下文或凭据。不升级Skill/Function、不重启;由运维准备hermes权限和已核实Agent键,再交王刚001在真实消息授权任务中执行一次。尚未称目标查重验收通过。
+
+## 14:56更新:查重实际执行及独立数据库验收通过
+
+用户转发王刚001实际执行回执:EXIT=0、ok=true、operation=sample-material-dedup-verified。固定任务键KSF-查重实测-20261102-01,仅运行一次,原目录与回执保留。
+
+本机随后独立只读查询生产来源、原料、导入任务及逐条记录,确认:
+
+| 项目 | 第一批 | 第二批 |
+|---|---|---|
+| 来源 | V6AS9Qf10d | V6AS9Qf10d |
+| 原料 | SGOF-c5vTo | SGOF-c5vTo |
+| 导入任务 | 2MDXH_CzC5 | L68OPSHuXW |
+| created | 1 | 0 |
+| unchanged | 0 | 1 |
+
+按固定原文SHA-256查询来源命中1条,按固定原料名称查询命中1条,任务键对应导入任务2条、导入明细2条,均succeeded。原文和全部样例业务字段精确一致;原料仍保留第一批externalKey,第二批明细保留第二批businessKey并指向相同原料。两批归属同一已核实Agent。本机核验未写业务库、不重放导入。
+
+独立证据 work/dedup-acceptance-readback-20261102.json,脚本 scripts/verify-dedup-acceptance-readback.mjs。调用invocationId来自用户转发工具回执,仅作为调用证据记录,不声称数据库独立认证了这些UUID。已独立认证的是业务编号、实际计数、来源/原料内容与导入复用结果。
+
+当前结论:真实企微身份登记及复用、S1–S5样例完整链路、项目/方案/原料工艺关联、生产看板与浏览器显示,以及同份样例资料跨批次查重复用均已通过实际验收。查重结论限于本次同内容样例;不表示语义近似内容会自动合并,不删除历史重复数据。审计和导入日志保留,数据库仍会随真实新增内容和日志增长。真实平台采集、市场验证、正式报告、员工实名绑定与secret轮换仍分别待完成。
+
+## 15:06更新:第一批真实官网资料已采集归档,待王刚001导入
+
+用户要求开始下一步。本机通过HTTPS实际取得统一官网 products-jiangbanmian.asp 与 products-qiaomianguan.asp,200响应,按网页字符集gb18030严格解码,保留响应原字节、HTML、提取全文、采集时间与SHA-256。数据是统一酱拌面4种及巧面馆拌面5种,共9个口味条目,均为官方产品列表自述,不是9个独立来源/精确SKU,也不代表消费者反馈或当下在售。
+
+已准备固定任务键KSF-真实资料-20261102-01,两份live来源、9个产品口味、9份逐字引文证据及1个明确标注的研究工作分类。缺价格、净含量、消费者评价、康师傅同口径对比等事项保留在pendingQuestions;不填写推测值。采集者为Codex;王刚001只执行已归档资料的导入,不冒称自己抓取。实际官网资料采集已完成,真实资料入库、S1小结及页面呈现尚待目标执行。
+
+本地77/77测试通过,涵盖真实业务处理代码入库/来源与引用逐项回读,以及损坏来源/引文写入前停止。只读目标查询本任务键导入0条,两份内容hash+URL+live来源均0条。本轮未执行业务库写入、未创建真实课堂会话/报告,未重跑K。证据work/first-live-history-readback-20261102.json。
+
+交付work/delivery-by-recipient-20261102/ksf-first-live-sources-20261102.zip,45841B,SHA-256=f5a7ab4706280c359823a70295511dd63cc16e1a7af52498f2f496a1c6f0b0bf,包内14/14校验一致。带独立执行器、内置指纹固定的批次JSON、原始采集归档、null身份键模板和运维/王刚分工说明。运维准备原有Agent档案键与hermes私有目录,不重启不改Skill/Function;王刚001新真实消息任务执行一次,失败即停,保留已有编号与回执。下一步回执核验后,另建真实资料工作坊会话组织S1小结,不把sample K会话改标签。
+
+## 15:15更新:第一批真实资料入库独立核验通过,S1小结已准备
+
+收到王刚001真实执行回执EXIT=0、operation=first-live-official-sources-imported。独立只读核验确认两份来源BgUB6dnxN1/SFiKA0D7n9正文、URL、取得时间、hash均与归档原件一致;9条产品与9条逐字引文实体编号逐项吻合,产品关联分类Mc9FJpryn4、引文关联产品和来源均正确。没有补写未知净含量或价格。第一批9个实体新增,第二批10个实体新增/1个分类复用;20个逐条导入任务及明细均succeeded,同一维护人核实Agent归属。此20是逐实体导入任务数,不是20个批次来源或20条独立证据。核验未写业务数据。证据work/first-live-readback-20261102.json。
+
+已准备S1真实资料首批小结,说明已掌握的9个口味、2个官网页面/1个厂商、包装与证据口径,按顺序列出自有对照、在售日期、SKU标签、渠道价格、消费者原文和企业技术资料等追问。两份sourceId固定关联,2段逐字引文保留;不写市场规模、优劣、人群偏好或量产结论。保存为live课堂过程成果,仍非正式确认/发布。新live会话不得复用sample K会话;员工/Agent须在新真实消息核对,真实会话键由维护人取可信来源,不在模板填造身份。
+
+交付ksf-first-live-S1-20261102.zip,7822B,SHA-256=8ce4b51a186559834b950fcc0515a6f70ab3eb42b93975507a83883c1658a75e,4/4校验通过。含固定S1保存模板(sessionId=null待填真实start返回值)、可读草稿、运维/王刚说明。16项工作坊专项测试通过,新增案例验证真实来源引文、公开课堂展示、不造机会/项目/正式确认;未执行目标start/save。等待目标保存回执后再独立核对报告和浏览器,不声称S1已在看板可见。
+
+## 15:32更新:真实S1保存、独立回读及浏览器验收通过
+
+收到王刚001start/save成功回执:sessionId=btrFqkz0yw、artifactId=cOm6c8gg7z、小结生成来源CI8dFPUdTz、live/S1、boardVisible=true、formalReportPublished=false、employeeConfirmed=false。其额外workshop-search尝试因参数错误失败,不能称其已独立回读。该失败不触发重新start/save。
+
+本机已用ksfGetRecord按实际编号独立只读查询:正文与固定模板一致,服务端增加正常的课堂声明/生成时间;关联两份真实来源BgUB6dnxN1/SFiKA0D7n9、两段逐字引文和六条pendingQuestions均正确,私有员工/Agent作用域匹配,同workshopKey仅1会话和1成果,公开投影可读且不含原始账号。原始页面仍为2个,CI8dFPUdTz是小结输出正文而非额外采集页面。证据work/first-live-S1-readback-20261102.json。
+
+随后匿名Edge浏览器读取/members、/stages、/report及/report/cOm6c8gg7z,穿透Shadow DOM核对S1标题、9个口味列表、待补问题和课堂声明。26项检查通过,无运行异常,电脑端与390px页面无横向溢出;已实际查看桌面/手机截图。证据work/first-live-S1-browser-20261102.json、work/first-live-S1-report-desktop.png、work/first-live-S1-report-mobile.png。此次browserVerified=true是本机独立页面验收结果,不回改王刚001的原始回执。本轮业务写入0。
+
+本次真实资料采集→查重入库→来源关联S1课堂小结→公开报告页面已完成。读已知编号应使用workshop-read,输入{className:KfArtifact,objectId:cOm6c8gg7z},不添加--query/--read-only,也不以search替代精确回读。下一步补康师傅同口径产品及SKU/价格/消费者原文,现有资料不足以得出整体市场或需求结论。员工实名绑定与密钥轮换仍未完成。

+ 69 - 0
docs/workshop-today-full-chain-20261102.md

@@ -0,0 +1,69 @@
+# 今天交付:真实企微消息驱动的工作坊完整闭环
+
+用户最新要求是今天把完整链路打通,先保证能运行,不继续扩展调研质量、模型评分或样式。沿用已验v4与十文件Skill,不再做一轮代码包评审。本件补齐原执行表只到S1的验收范围,不把“新增工具装好”当完成。
+
+## 今日通过标准
+
+1. 王刚001新的真实企微消息可调用ksf_register_current_employee,返回本条员工档案;同员工第二条消息复用,changed=false。维护人独立核对发送者与bot作用域,不造员工账号或人工身份绕过。
+2. 保存一组sample分类、产品、原料和工艺记录,取得真实库内编号;保留未知规格/配比/工艺/成本,不造真实市场材料。
+3. 形成S1品类竞品清单、S2人群痛点清单、S3机会卡;用workshop-search检索机会卡,以本次检索到的真实编号选择建独立项目。
+4. 项目S3产出→S4推进→blocked并确认公开数据可见→恢复课堂练习→S4验证缺口报告→S5推进。
+5. 保存新品方案及原料/工艺关联,含人群、场景、规格、包装候选、风味、卖点和未决项;用量未知省略,完成指课堂任务,不代表审批/量产。
+6. 保存S5报告、项目课堂完成;精确回读session/artifact/project/concept/配料/工艺引用,公开看板项目与报告数据可见,真实浏览器确认成员页项目进展和报告正文。
+
+原始G/H与11月1日管理机联调记录不重复执行,不把旧的“王刚001(联调模拟)”当新真实消息。全程sample,不做report-submit/员工确认/正式发布。
+
+## 一、运维先落地已审代码
+
+按第二阶段表执行固定两个Function升级、单实例平台模块与Skill升级、维护人真实Agent建档与registration.json、清单留存、指定服务级重启。安装/建档/私有状态/加密命令用hermes(uid10000),root仅必要平台文件/宿主备份/s6控制。plugin.yaml正确SHA=d19eea8907df4417290df93cb75181aa290b9b7f1ffeb316f3a8db39db09ff9b。
+
+将用户最新“今天把完整链路实现好”的要求用于推进已审范围内的必要安装、两函数更新、指定网关服务重启及sample验收;不再等待下一轮技术确认。运维在今天可执行的维护窗口安排并直接告知开始/结束时间,继续保留4310/4320/4330可能中断的说明;不把潜在中断扩大成清预览授权。FILES复制已是安装元数据步骤,无需单问。不要再自行生成下一轮准备/等待文档,不以可选调优阻塞。
+
+不清预览、不自动docker restart、不动其它容器、不改看板样式/r7/schema/权限。服务恢复核对PID、订阅和两个工具再交王刚001。轮换维护人未就绪时明确上报,单列未解决项,不能声称已轮换;不要索要明文secret。KSF令牌有效性与bot secret不同,执行前按既有入口核验,到期/撤销停在对应动作。
+
+## 二、真实消息登记(王刚001)
+
+新消息调用准确名称ksf_register_current_employee({})。原始账号不回显,缺上下文/工具缺失就停,不换终端/JSON。成功后用户发第二条真实消息,再调用并核同键、changed=false。这两条不能由管理机脚本冒充。
+
+运维核对本地档案和真实入站发送者后,把工具返回agentProfileKey、profile.key,以及已核实当前实际会话键写到私有验收context.json(0600/uid10000)。此文件只配置本轮任务,不能作为身份登记来源,不能用模板自行建员工档案。会话键仍属私有信息,不发公开报告。
+
+context形状:
+
+    {
+      "version": "ksf.full-workshop-acceptance.v1",
+      "workshopKey": "KSF-企微实测-20261101-K",
+      "agentProfileKey": "工具返回的agent-account:64hex",
+      "participantProfileKey": "工具返回的participant-account:64hex",
+      "conversationKey": "已核实本次真实会话键",
+      "groupId": ""
+    }
+
+KSF-企微实测-20261101-K是既定未执行轮次。执行前受控管理端按workshopKey/历史回执/本地日志确认状态:没有执行才启动全流程;已有部分记录则用原键和回执逐项恢复未完成步骤,禁止从头自动重跑/换键;已有成功就回读,不当第二次新增。不要修改本脚本绕过状态。K状态不清就报告哪个记录不清,而不是另外造轮次。
+
+## 三、完整业务验收执行器
+
+scripts/workshop-full-chain.mjs仅编排既有ksf-secure入口;不创建身份、不读取明文凭据、不修改Skill或服务配置。须由已恢复的王刚001在真实消息授权的任务中执行;运维可安装/准备执行器,不在自己的Agent身份上冒跑学员任务。
+
+脚本依次调用合同/start/import/search/save/project-create/project-progress/concept-save/project-status/read,用固定requestId和任务键,逐步保存输入/两层原始回执/状态journal。失败停止,没有自动重试/续跑/换键/清理。state-dir必须是不存在的新绝对目录,父目录先由维护人以hermes创建为700;已存在表示需先核对历史,不删目录重跑。输入与日志含私有会话关联,仅留受控实例,不上传聊天全量输入。
+
+在目标hermes用户、已核实真实路径,执行形式如下;占位路径应替换为实际准备路径:
+
+    /usr/local/bin/node /opt/data/本次交付/scripts/workshop-full-chain.mjs --input /opt/data/本次私有目录/context.json --state-dir /opt/data/本次私有目录/full-chain-K --execute
+
+不要在本机Windows或运维001容器直接运行真实写入;本机测试使用模拟store,生产必须经目标加密入口。若服务令牌缺source:write,导入会失败并保留回执;不改权限绕过,按既有授权范围核对配置。其余业务阶段失败同样保留真实编号,不自动删记录。
+
+今天执行器会生成6份报告:S1/S2/S3各一份,以及项目下S3/S4/S5三份;另有1组分类/产品/原料/工艺,1张机会卡、1个项目、1版方案和1条配料关系。不是全行业调研,数量不代表研究覆盖。blocked中间态与完成终态均检查公开数据。
+
+脚本成功输出operation=full-workshop-api-chain-verified、stage=S5、progressState=completed、公开数据校验通过、browserVerified=false。这不是网页验收结果,仍需下一步真实浏览器。
+
+## 四、回读与页面验收
+
+运维受控管理端独立回读本轮session/project/artifact的私有归属:真实participant账户、bot scope、王刚001 Agent键、sample标签。仅回“匹配/不匹配”,不公开原账号或完整元数据。
+
+匿名浏览器检查/pool、/stages、/members、/report/<本轮artifactId>,确认所选机会、S1–S5成果、项目S5课堂完成、负责人待绑定标签/实际Agent名称、报告正文和sample声明;页面使用Shadow DOM,普通body.innerText不足以判白屏。看板由公开云函数取数,不携服务令牌,不改样式或要求员工登录。桌面及390px检查关键页面即可,失败修对应接入问题,不扩展UI改版。
+
+公开API可见不等于页面验收通过。最终分别回报API闭环、私有归属回读、真实浏览器结果。输出sessionId/opportunityId/projectId/conceptId/artifactId、报告URL、两层回执与实际未完成项。原料/工艺/配料引用必须是真实返回编号,不能按名称猜。
+
+## 今天不继续扩大范围
+
+PPT、逐阶段演讲稿/提示词手册、全品类真实调研与高质量知识库是后续工作坊筹备交付,今天的目标是上述系统链路。正式报告确认/发布在既有员工通道未开通时仍停预览,不为了“全流程”绕过。secret暴露轮换仍单列追踪,链路测试成功不表示该事项消失。

+ 8 - 0
integrations/wecom-identity/__init__.py

@@ -0,0 +1,8 @@
+"""Prepared composed entry; replaces plugin entry only during approved installation."""
+from plugins.platforms.wecom.identity_bridge import register as _register_query
+from plugins.platforms.wecom.workshop_registration import register as _register_registration
+
+
+def register(ctx):
+    _register_query(ctx)
+    _register_registration(ctx)

+ 180 - 0
integrations/wecom-identity/identity_bridge.py

@@ -0,0 +1,180 @@
+"""工具桥 v3:把「当前这次工具调用」绑定到可信身份(隔离副本,未上机)
+
+v3 修订(对应 2026-11-02 二轮复核 1/2/4/5):
+1. **不再回落到 os.environ**:`get_session_env()` 在 ContextVar 为 `_UNSET` 时会去读环境变量
+   (源码注释:「never set here」→ falls back to os.environ),这正是「合成 env 就能骗过」的口子。
+   v3 直接读 `gateway.session_context._VAR_MAP` 里的 ContextVar:`_UNSET` → 空串(本回合未绑定),
+   **绝不查环境变量**;`_VAR_MAP` 不存在 → `SESSION_READER_UNAVAILABLE`(明确失败)。
+2. **有效会话键**:单聊原始帧无 chatid 时落盘 `chatId=null`,而 `SessionSource.chat_id=sender_id`,
+   故核对用 `aibot_identity.effective_chat_id()`(= chatId or senderId),不再拿原始 chatId 硬比。
+3. **botId 唯一来源 = 受控实例登记**(适配器自己写 `instance.json`),删掉顶层优先/extra 优先/正则兜底那套猜测。
+4. **只服务 aibot**:`wecom_callback` 明确不支持(回调模式的来源/字段是另一套,不混接)。
+"""
+from __future__ import annotations
+
+import json
+import logging
+import os
+import pathlib
+from typing import Any, Callable, Dict, Optional
+
+try:  # 运行时(平台插件包内)
+    from plugins.platforms.wecom.aibot_identity import (  # type: ignore
+        DEFAULT_TTL_SECONDS, IdentityRejected, effective_chat_id, identity_summary,
+        normalize_session_chat_type, read_context, read_instance_registry,
+    )
+except ImportError:  # 离线夹具/独立加载
+    import sys
+    sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent))
+    from aibot_identity import (  # type: ignore
+        DEFAULT_TTL_SECONDS, IdentityRejected, effective_chat_id, identity_summary,
+        normalize_session_chat_type, read_context, read_instance_registry,
+    )
+
+logger = logging.getLogger(__name__)
+
+TOOL_NAME = "wecom_identity_current"
+TOOLSET = "wecom"
+SUPPORTED_PLATFORMS = ("wecom",)   # 只 aibot;wecom_callback 不接
+
+_UNSET_SENTINEL = object()
+
+
+class SessionReaderUnavailable(Exception):
+    """生产会话读取器不可用(版本不符/缺变量)—— 不许退回环境变量猜。"""
+
+    def __init__(self, code: str):
+        super().__init__(code)
+        self.code = code
+
+
+def strict_session_reader() -> Callable[[str], str]:
+    """**无环境变量回落**的当前回合读取器:只认 ContextVar。
+
+    生产 reader 缺失(`_VAR_MAP` 不在)或某个变量缺失 → 抛 SessionReaderUnavailable。
+    `_UNSET`(本回合未绑定)→ 返回 ""(空),由上层判失败关闭;**不查 os.environ**。
+    """
+    try:
+        import gateway.session_context as session_context  # type: ignore
+    except Exception as exc:  # noqa: BLE001
+        raise SessionReaderUnavailable("SESSION_READER_UNAVAILABLE") from exc
+
+    var_map = getattr(session_context, "_VAR_MAP", None)
+    unset = getattr(session_context, "_UNSET", None)
+    if not isinstance(var_map, dict) or "HERMES_SESSION_MESSAGE_ID" not in var_map:
+        raise SessionReaderUnavailable("SESSION_READER_UNAVAILABLE")
+    if unset is None:
+        # 没有 `_UNSET` 哨兵就无法区分「本回合未绑定」与「真值」——
+        # 若退回自造哨兵,未绑定的 ContextVar 会把内部对象 repr 当身份值输出。
+        # 一律固定失败码(不输出原始异常)。
+        raise SessionReaderUnavailable("SESSION_READER_UNAVAILABLE")
+
+    def _read(name: str) -> str:
+        var = var_map.get(name)
+        if var is None:
+            raise SessionReaderUnavailable(f"SESSION_VAR_MISSING:{name}")
+        try:
+            value = var.get()
+        except Exception:  # noqa: BLE001 — 固定码,不泄原始异常
+            raise SessionReaderUnavailable("SESSION_VAR_UNAVAILABLE") from None
+        if value is unset:            # 本回合未绑定 —— 明确为空,**不回落 env**
+            return ""
+        if isinstance(value, (str, bytes)):
+            return value.decode("utf-8", "replace") if isinstance(value, bytes) else value
+        # 非字符串值不该出现在会话变量里:视为不可信,固定失败码
+        raise SessionReaderUnavailable("SESSION_VAR_UNAVAILABLE")
+
+    return _read
+
+
+def current_identity(args: Any = None, *, reader: Optional[Callable[[str], str]] = None,
+                     directory: Optional[str] = None,
+                     ttl_seconds: int = DEFAULT_TTL_SECONDS,
+                     now: Optional[float] = None,
+                     instance_dir: Optional[str] = None) -> Dict[str, Any]:
+    """返回**已核验**的本次调用身份;任何不确定一律 ok=False(失败关闭)。"""
+    try:
+        read = reader or strict_session_reader()
+    except SessionReaderUnavailable as exc:
+        return {"ok": False, "error": exc.code}
+
+    try:
+        platform = str(read("HERMES_SESSION_PLATFORM") or "").strip().lower()
+        message_id = str(read("HERMES_SESSION_MESSAGE_ID") or "").strip()
+        sender_id = str(read("HERMES_SESSION_USER_ID") or "").strip()
+        raw_type = str(read("HERMES_SESSION_CHAT_TYPE") or "").strip()
+        session_chat_id = str(read("HERMES_SESSION_CHAT_ID") or "").strip() or None
+    except SessionReaderUnavailable as exc:
+        return {"ok": False, "error": exc.code}
+
+    if not platform:
+        # 本回合没有绑定平台 = 没有会话上下文(不是「别的平台」)
+        return {"ok": False, "error": "SESSION_IDENTITY_INCOMPLETE",
+                "have": {"platform": False}}
+    if platform not in SUPPORTED_PLATFORMS:
+        # 含 wecom_callback —— 明确不接,不猜
+        return {"ok": False, "error": "UNSUPPORTED_PLATFORM", "platform": platform}
+
+    registry = read_instance_registry(instance_dir or directory)
+    if registry is None:
+        return {"ok": False, "error": "INSTANCE_REGISTRY_MISSING"}   # 不猜配置、不读环境变量
+    trusted_bot = str(registry["botId"]).strip()
+
+    if not (message_id and sender_id):
+        return {"ok": False, "error": "SESSION_IDENTITY_INCOMPLETE",
+                "have": {"message_id": bool(message_id), "sender_id": bool(sender_id)}}
+
+    if isinstance(args, dict):
+        for key, trusted in (("message_id", message_id), ("sender_id", sender_id), ("bot_id", trusted_bot)):
+            supplied = str(args.get(key) or "").strip()
+            if supplied and supplied != trusted:
+                return {"ok": False, "error": "CALLER_VALUE_CONFLICT", "field": key}
+
+    chat_type = normalize_session_chat_type(raw_type)
+    if chat_type is None:
+        return {"ok": False, "error": "UNKNOWN_CHAT_TYPE", "chat_type": raw_type}
+    if chat_type == "group" and not session_chat_id:
+        return {"ok": False, "error": "MISSING_GROUP_CHAT_ID"}
+    # 有效会话键:单聊 = session chat_id or sender_id(与 `chat_id = chatid or sender_id` 同一条规则);
+    # 群聊必须显式有 chat_id。这样「单聊帧无 chatid」不会因为落盘 chatId=null 而被误判。
+    expected_chat = session_chat_id if chat_type == "group" else (session_chat_id or sender_id)
+
+    try:
+        ctx = read_context(trusted_bot, message_id,
+                           expect={"senderId": sender_id, "chatType": chat_type,
+                                   "chatId": expected_chat},   # 有效会话键
+                           directory=directory, ttl_seconds=ttl_seconds, now=now)
+    except IdentityRejected as exc:
+        return {"ok": False, "error": exc.code}
+    if ctx is None:
+        return {"ok": False, "error": "IDENTITY_CONTEXT_UNAVAILABLE",
+                "detail": "无本消息的可信上下文(未落盘/已过期/字段与当前调用不一致)"}
+
+    return {"ok": True,
+            "trust": "gateway_contextvar(no-env-fallback)+adapter_instance_registry",
+            "identity": identity_summary(ctx),
+            "effectiveChatId": effective_chat_id(ctx["chatType"], ctx["chatId"], ctx["senderId"])}
+
+
+TOOL_SCHEMA = {
+    "name": TOOL_NAME,
+    "description": ("返回**本次消息**已核验的企微 aibot 发送者身份(botId/senderId/chatType/chatId/messageId)。"
+                    "身份取自网关会话上下文与本实例受控登记,模型无法指定,也不需要任何参数。"
+                    "ok=false 时必须停止身份登记,不得改用正文、session 键或旧文件。"),
+    "parameters": {"type": "object", "properties": {}, "additionalProperties": False},
+}
+
+
+def _handler(args: Optional[Dict[str, Any]] = None, **_kw: Any) -> str:
+    # Only the registered tool returns text. Registration calls current_identity()
+    # directly and still requires its structured, verified identity result.
+    if args is not None and (not isinstance(args, dict) or args):
+        return json.dumps({"ok": False, "error": "NO_ARGUMENTS_ALLOWED"})
+    return json.dumps(current_identity(), ensure_ascii=False)
+
+
+def register(ctx) -> None:
+    """插件入口:`ctx.register_tool(name, toolset, schema, handler, …)`。"""
+    ctx.register_tool(TOOL_NAME, TOOLSET, TOOL_SCHEMA, _handler,
+                      description=TOOL_SCHEMA["description"], emoji="🪪")
+    logger.info("wecom-identity bridge loaded: tool=%s version=v3", TOOL_NAME)

+ 5 - 0
integrations/wecom-identity/registration.example.json

@@ -0,0 +1,5 @@
+{
+  "version": "ksf.current-employee-registration.v1",
+  "agentProfileKey": null,
+  "botIdSha256": null
+}

+ 128 - 0
integrations/wecom-identity/workshop_registration.py

@@ -0,0 +1,128 @@
+"""No-argument in-process registration, composed with the reviewed aibot bridge.
+
+No identity JSON is accepted from model tool arguments. The Node worker reuses
+the existing Skill key/profile code; its stdin is private transport, not proof.
+This does not isolate arbitrary same-UID terminal code from local state.
+"""
+from __future__ import annotations
+
+import hashlib
+import json
+import os
+from pathlib import Path
+import re
+import shutil
+import stat
+import subprocess
+
+TOOL_NAME = 'ksf_register_current_employee'
+WORKER = Path('/opt/data/skills/ksf-workshop-runtime/scripts/ksf-identity-bridge.mjs')
+TOOL_SCHEMA = {
+    'name': TOOL_NAME,
+    'description': '登记当前企微消息发送者,返回员工档案键。无需参数;从本回合可信上下文读取,失败停止,不接受身份转述。只写本地档案,不建会话或写业务库。',
+    'parameters': {'type': 'object', 'properties': {}, 'additionalProperties': False},
+}
+
+
+class RegistrationError(Exception):
+    pass
+
+
+def _check_private_file(path):
+    # Permissions are defensive checks, not a same-UID provenance guarantee.
+    for parent in (path, *path.parents):
+        if parent.is_symlink():
+            raise RegistrationError('REGISTRATION_CONFIG_UNSAFE')
+    s = path.lstat()
+    if not stat.S_ISREG(s.st_mode):
+        raise RegistrationError('REGISTRATION_CONFIG_UNSAFE')
+    if os.name == 'posix' and (s.st_uid != os.geteuid() or stat.S_IMODE(s.st_mode) != 0o600):
+        raise RegistrationError('REGISTRATION_CONFIG_UNSAFE')
+
+
+def _read_maintenance_config():
+    home = Path(os.environ.get('HERMES_HOME') or Path.home())
+    path = home / 'plugins' / 'wecom-identity' / 'registration.json'
+    _check_private_file(path)
+    if path.stat().st_size > 4096:
+        raise RegistrationError('REGISTRATION_CONFIG_INVALID')
+    data = json.loads(path.read_text(encoding='utf-8'))
+    if not isinstance(data, dict) or set(data) != {'version', 'agentProfileKey', 'botIdSha256'}:
+        raise RegistrationError('REGISTRATION_CONFIG_INVALID')
+    if data['version'] != 'ksf.current-employee-registration.v1':
+        raise RegistrationError('REGISTRATION_CONFIG_INVALID')
+    if not isinstance(data['agentProfileKey'], str) or not re.fullmatch(r'agent-account:[a-f0-9]{64}', data['agentProfileKey']):
+        raise RegistrationError('REGISTRATION_CONFIG_INVALID')
+    if not isinstance(data['botIdSha256'], str) or not re.fullmatch(r'[a-f0-9]{64}', data['botIdSha256']):
+        raise RegistrationError('REGISTRATION_CONFIG_INVALID')
+    return data
+
+
+def _current_identity():
+    from plugins.platforms.wecom.identity_bridge import current_identity
+    return current_identity()  # No model args, reader override, or message-id override.
+
+
+def _receipt(result, agent_key):
+    # Reconstruct the public receipt; do not echo worker stderr or raw account fields.
+    if not isinstance(result, dict) or result.get('ok') is not True or result.get('agentProfileKey') != agent_key:
+        raise RegistrationError('REGISTRATION_RECEIPT_INVALID')
+    p = result.get('profile')
+    if not isinstance(p, dict) or not re.fullmatch(r'participant-account:[a-f0-9]{64}', str(p.get('key', ''))):
+        raise RegistrationError('REGISTRATION_RECEIPT_INVALID')
+    if p.get('identityBasis') != 'channel_context' or not isinstance(p.get('name'), str) or not p['name'].strip():
+        raise RegistrationError('REGISTRATION_RECEIPT_INVALID')
+    if not isinstance(result.get('changed'), bool) or not isinstance(result.get('needsNameBinding'), bool):
+        raise RegistrationError('REGISTRATION_RECEIPT_INVALID')
+    iid = result.get('invocationId', '')
+    if not isinstance(iid, str) or not re.fullmatch(r'[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}', iid):
+        raise RegistrationError('REGISTRATION_RECEIPT_INVALID')
+    return {
+        'ok': True, 'operation': 'current-employee-registered', 'invocationId': iid,
+        'changed': result['changed'], 'agentProfileKey': agent_key,
+        'profile': {'key': p['key'], 'name': p['name'], 'identityBasis': 'channel_context'},
+        'needsNameBinding': result['needsNameBinding'], 'currentMessageChecked': True,
+        'accountAuthenticated': False, 'displayNameAuthenticated': False,
+        'networkRequests': 0, 'databaseWrites': 0,
+    }
+
+
+def _register_current_employee_result(args=None, **_kw):
+    if args is not None and (not isinstance(args, dict) or args):
+        return {'ok': False, 'error': 'NO_ARGUMENTS_ALLOWED'}
+    try:
+        checked = _current_identity()
+        if not isinstance(checked, dict) or checked.get('ok') is not True:
+            return {'ok': False, 'error': 'IDENTITY_CONTEXT_UNAVAILABLE'}
+        identity = checked.get('identity')
+        if not isinstance(identity, dict) or not isinstance(identity.get('botId'), str):
+            raise RegistrationError('INVALID_CHANNEL_CONTEXT')
+        config = _read_maintenance_config()
+        if hashlib.sha256(identity['botId'].encode('utf-8')).hexdigest() != config['botIdSha256']:
+            raise RegistrationError('REGISTRATION_INSTANCE_MISMATCH')
+        node = shutil.which('node')
+        if not node or not WORKER.is_file() or WORKER.is_symlink():
+            raise RegistrationError('REGISTRATION_WORKER_UNAVAILABLE')
+        request = {'version': config['version'], 'agentProfileKey': config['agentProfileKey'], 'identity': identity}
+        proc = subprocess.run([node, str(WORKER)], input=json.dumps(request, ensure_ascii=False),
+                              capture_output=True, text=True, encoding='utf-8', timeout=20, check=False)
+        if proc.returncode != 0:
+            raise RegistrationError('REGISTRATION_WORKER_FAILED')
+        if len(proc.stdout) > 16384:
+            raise RegistrationError('REGISTRATION_RECEIPT_INVALID')
+        return _receipt(json.loads(proc.stdout), config['agentProfileKey'])
+    except RegistrationError as exc:
+        return {'ok': False, 'error': str(exc)}
+    except Exception:
+        # Exceptions can contain config/worker output; no raw exception logging.
+        return {'ok': False, 'error': 'IDENTITY_REGISTRATION_FAILED'}
+
+
+def register_current_employee(args=None, **_kw):
+    """Hermes tool boundary accepts text; internal callers keep structured data."""
+    return json.dumps(_register_current_employee_result(args, **_kw), ensure_ascii=False)
+
+
+def register(ctx):
+    ctx.register_tool(TOOL_NAME, 'wecom', TOOL_SCHEMA, register_current_employee,
+                      description=TOOL_SCHEMA['description'], emoji='🪪')

+ 2 - 0
package.json

@@ -6,6 +6,8 @@
   "description": "康师傅产品创新看板 · 无构建原生 ESM + Lit(core / wc / page 三层)",
   "sideEffects": false,
   "scripts": {
+    "test:workshop": "node --test server/test/application.test.mjs server/test/workshop.test.mjs server/test/board-actions.test.mjs server/test/identity-deploy.test.mjs server/test/dedup-deploy.test.mjs test/workshop*.test.js",
+    "build:workshop": "node server/scripts/build.mjs && node scripts/build-workshop-skill.mjs",
     "verify:visual-v12": "node scripts/verify-visual-v12.mjs",
     "verify:p2": "node scripts/verify-visual-v12.mjs --tag before --json docs/v12-after-p2.json",
     "config": "node scripts/config.mjs local",

+ 39 - 0
scripts/assemble-board-handoff.py

@@ -0,0 +1,39 @@
+"""Assemble deployment tooling around the frozen, publicly baselined source."""
+import hashlib
+import json
+from pathlib import Path
+import subprocess
+
+ROOT = Path(__file__).resolve().parents[1]
+PKG = ROOT / 'work/board-deploy-20261106-r1/package'
+manifest = json.loads((PKG / 'deploy/board-files.json').read_text(encoding='utf-8'))
+publisher = (ROOT / 'scripts/publish-opportunity-ui.mjs').read_text(encoding='utf-8')
+start = publisher.index('export const FILES=Object.freeze([')
+end = publisher.index(']);', start) + 3
+publisher = publisher[:start] + 'export const FILES=Object.freeze(' + json.dumps([f['path'] for f in manifest['files']]) + ');' + publisher[end:]
+publisher = publisher.replace('ksf.opportunity-ui.publish.v1', 'ksf.board.publish.v1').replace('ksf.opportunity-ui.20261103.v1', 'ksf.board.20261106.r1')
+publisher = publisher.replace('filesChecked:6', 'filesChecked:FILES.length')
+publisher = publisher.replace('ksf-opportunity-ui', 'ksf-board')
+publisher = publisher.replace("sourceDir:root,manifestPath:path.join(root,'deploy/opportunity-ui-files-20261103.json')", "sourceDir:path.join(root,'payload'),manifestPath:path.join(root,'deploy/board-files.json')")
+(PKG / 'scripts').mkdir(exist_ok=True)
+(PKG / 'scripts/publish-board.mjs').write_text(publisher, encoding='utf-8', newline='\n')
+for name in ('test_board_library.py', 'test_retrieval.py', 'test_voc_db.py'):
+    p = 'server/voc-library/' + name
+    (PKG / 'payload' / p).write_bytes(subprocess.check_output(['git', 'show', manifest['commit'] + ':' + p], cwd=ROOT))
+
+metadata = {
+    'release': manifest['release'], 'commit': manifest['commit'], 'origin': manifest['origin'],
+    'productionInstalled': False, 'databaseIncluded': False, 'skillsIncluded': False,
+    'homepageIncluded': False, 'devServerIncluded': False, 'credentialsIncluded': False,
+    'staticFiles': len(manifest['files']), 'expectedStaticChanges': sum(f['changed'] for f in manifest['files']),
+    'expectedNewStaticFiles': sum(f['allowMissing'] for f in manifest['files']),
+    'functions': json.loads((PKG / 'payload/server/dist/member-groups/manifest.json').read_text())['functions'],
+    'requiredDatabasePackage': {'name': 'ksf-voc-database-20261106-r1.zip',
+        'bytes': 25913256, 'sha256': '1ba1ac2fa6a8b774f8945d88e014c07dd5e03332792af298a41f015ba6946f9f'},
+    'database': {'schema': 'ksf.voc-library.v2',
+        'generation': '7e0df42395d9d2ea39dd5b537d17ae35ac910cc29721ed92a680b7fe6b25c92c',
+        'bytes': 98918400, 'sha256': '944e4bb3a5d65dd897ea7c912e1af001aa845db1ba1f968a7b0ee653dc60fa50'},
+    'productionPreflight': {'readOnly': True, 'productionWrites': 0, 'memberGroupsReadHttp': 404, 'libraryVersionHttp': 404},
+}
+(PKG / 'RELEASE.json').write_text(json.dumps(metadata, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
+print(json.dumps({'ok': True, 'staticFiles': metadata['staticFiles'], 'expectedChanges': metadata['expectedStaticChanges'], 'functionCount': len(metadata['functions'])}))

+ 53 - 0
scripts/audit-release-workspace.py

@@ -0,0 +1,53 @@
+"""Inventory source changes without including private data, generated releases or retired demos."""
+import hashlib
+import json
+from pathlib import Path
+import re
+import subprocess
+
+ROOT = Path(__file__).resolve().parents[1]
+ALLOWED = ('scripts/', 'docs/', 'deploy/', 'integrations/', 'server/', 'skills/', 'test/')
+
+def main():
+    pending = subprocess.check_output(['git', 'ls-files', '--others', '--exclude-standard', '-z'], cwd=ROOT).decode('utf-8').split('\0')
+    for args in (['git', 'diff', '--name-only', '-z'], ['git', 'diff', '--cached', '--name-only', '-z']):
+        pending += subprocess.check_output(args, cwd=ROOT).decode('utf-8').split('\0')
+    candidates = sorted({p for p in pending if p and (p.startswith(ALLOWED) or p in {'.gitignore', '.gitattributes', 'package.json'})})
+    findings = []
+    forbidden = []
+    for name in candidates:
+        p = ROOT / name
+        if not p.exists():
+            continue
+        if p.is_symlink() or p.suffix.lower() in {'.zip', '.sqlite', '.db', '.pem', '.key', '.pyc', '.png'} or name.endswith('credential-binding.json'):
+            forbidden.append(name)
+            continue
+        text = p.read_text(encoding='utf-8-sig')
+        for m in re.finditer(r'''(?i)\b(master_?key|service_?token|access_?token|api_?key|client_?secret)\b\s*[:=]\s*['"]([^'"\n]{16,})['"]''', text):
+            if not ('test/' in name or 'server/test/' in name or Path(name).name.startswith('test_')):
+                findings.append({'path': name, 'line': text.count('\n', 0, m.start()) + 1, 'field': m[1], 'literalLength': len(m[2])})
+        if re.search(r'-----BEGIN (?:RSA |OPENSSH )?PRIVATE KEY-----\s+[A-Za-z0-9+/]{40}', text):
+            forbidden.append(name)
+    skills = {}
+    for skill in (ROOT / 'skills').iterdir():
+        manifest = skill / 'FILES.sha256'
+        if not manifest.is_file():
+            continue
+        rows = manifest.read_text(encoding='utf-8').splitlines()
+        for line in rows:
+            expected, relative = line.split('  ', 1)
+            actual = hashlib.sha256((skill / relative).read_bytes()).hexdigest()
+            if expected != actual:
+                raise ValueError('SKILL_MANIFEST_MISMATCH:' + skill.name + '/' + relative)
+        skills[skill.name] = {'filesVerified': len(rows), 'manifestSha256': hashlib.sha256(manifest.read_bytes()).hexdigest()}
+    out = {'ok': not forbidden and not findings, 'sourceCandidates': candidates, 'sourceCandidateCount': len(candidates),
+           'forbiddenCandidates': forbidden, 'literalCredentialCandidates': findings, 'skills': skills,
+           'excludedLocalOutputs': ['artifacts/', 'data/', 'work/', 'private database files', 'credentials', 'retired demo drafts'],
+           'productionWrites': 0}
+    target = ROOT / 'work/complete-release-20261106'
+    target.mkdir(parents=True, exist_ok=True)
+    (target / 'workspace-audit.json').write_text(json.dumps(out, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
+    print(json.dumps({k:v for k,v in out.items() if k != 'sourceCandidates'}, ensure_ascii=False, indent=2))
+
+if __name__ == '__main__':
+    main()

+ 14 - 0
scripts/build-dedup-manifest.mjs

@@ -0,0 +1,14 @@
+import fs from 'node:fs';
+import crypto from 'node:crypto';
+const sha=x=>crypto.createHash('sha256').update(x).digest('hex');
+// Read-only live baseline captured 2026-11-02; never accept a marker string as a version.
+const targets=[
+ {name:'ksfRegisterSource',objectId:'czT35BZZlx',beforeSha256:['87b67685914899167028339d4026052538b537fff2ca3c154c3a8a01a98ce18d','9cdbea619b4c985cf4365a3f9e28755aff1b233de19cea102ded9ff3fe695492']},
+ {name:'ksfValidateImport',objectId:'ra9OWsmbtC',beforeSha256:['0dfa296aa007beee40ff99f2bad6f66649c291da259f45270b9f05be5c011720','e6edde794e776b936736a14fd302e1caa03184d4f47604e0703f986f0afa27de']},
+ {name:'ksfSubmitImport',objectId:'gPjyoZbHhU',beforeSha256:['a5c763810c3be2523c252768fab5b90b742fdb8c43eec25368db0758e954ab4d','dc434030e5d36b8f80dbd6d4f5c85e055c8dc96e38ee4bd07750b44281d41d36']},
+ {name:'ksfGetIngestContract',objectId:'dWSrHp4FfQ',beforeSha256:['c20d242893c74e82d88c1c7d27784e3d80387bbb2601c81afafc146565c6c35c','49084745095889fe779e62c27bc7717873c1239df93b50c70e0570f1a7db3524','68b2d7aba8e2b7320d05d5eb16e9d7d2a3cd0b0c41a4c6d1d5089d4323ef59e4']},
+ {name:'ksfCheckDuplicates',objectId:'JDNsVUCXSo',beforeSha256:['3bec0e1277dee1734ad5c161d90350c07b2eeea6267fc07113d183b62fa24cca']}
+];
+const manifest={version:'ksf.dedup-deployment.v1',appId:'FKieRbIoGt_main',functions:targets.map(x=>({...x,path:'/'+x.name,file:'dist/'+x.name+'.js',sha256:sha(fs.readFileSync('server/dist/'+x.name+'.js'))}))};
+fs.writeFileSync('server/dist/dedup-manifest.json',JSON.stringify(manifest,null,2)+String.fromCharCode(10));
+console.log(JSON.stringify({manifest:'server/dist/dedup-manifest.json',functions:manifest.functions.map(x=>({name:x.name,sha256:x.sha256}))}));

+ 8 - 0
scripts/build-identity-manifest.mjs

@@ -0,0 +1,8 @@
+import fs from 'node:fs';
+import crypto from 'node:crypto';
+const expected=[
+ {name:'ksfStartWorkshopSession',objectId:'yKpUay6RDG',beforeSha256:'d5cc3efd740084b82334a4961eb821f0de424543fab742d032c96e9745c421b7'},
+ {name:'ksfGetIngestContract',objectId:'dWSrHp4FfQ',beforeSha256:'c20d242893c74e82d88c1c7d27784e3d80387bbb2601c81afafc146565c6c35c'}
+];
+const manifest={version:'ksf.identity-deployment.v1',appId:'FKieRbIoGt_main',functions:expected.map(x=>({...x,path:'/'+x.name,file:'dist/'+x.name+'.js',sha256:crypto.createHash('sha256').update(fs.readFileSync('server/dist/'+x.name+'.js')).digest('hex')}))};
+fs.writeFileSync('server/dist/identity-manifest.json',JSON.stringify(manifest,null,2)+'\n');console.log(JSON.stringify(manifest,null,2));

+ 13 - 0
scripts/build-workshop-skill.mjs

@@ -0,0 +1,13 @@
+import fs from 'node:fs';
+import crypto from 'node:crypto';
+const root='skills/ksf-workshop-runtime',sha=x=>crypto.createHash('sha256').update(x).digest('hex');
+const hash=sha(fs.readFileSync(root+'/scripts/ksf-workshop.mjs'));
+const identityHash=sha(fs.readFileSync(root+'/scripts/ksf-identity.mjs'));
+const file=root+'/scripts/ksf-secure.mjs';
+fs.writeFileSync(file,fs.readFileSync(file,'utf8')
+ .replace(/const WORKSHOP_ENTRY_HASH = '[^']+';/,"const WORKSHOP_ENTRY_HASH = '"+hash+"';")
+ .replace(/const IDENTITY_MODULE_HASH = '[^']+';/,"const IDENTITY_MODULE_HASH = '"+identityHash+"';"));
+const files=['SKILL.md','references/identity.md','references/workshop-tasks.md','references/write-tasks.md','runtime-lock.json','scripts/ksf-identity.mjs','scripts/ksf-identity-bridge.mjs','scripts/ksf-secure.mjs','scripts/ksf-workshop.mjs','scripts/ksf.mjs'];
+const entries=files.map(file=>sha(fs.readFileSync(root+'/'+file))+'  '+file);
+fs.writeFileSync(root+'/FILES.sha256',entries.sort().join('\n')+'\n');
+console.log(JSON.stringify({skill:root,files:entries.length,workshopEntrySha256:hash,identityModuleSha256:identityHash,legacyEntryPreserved:sha(fs.readFileSync(root+'/scripts/ksf.mjs'))==='7ef128f7d022bfbc15c0ec813bfd87995c6297c6efe407a563c840f014aba477'}));

+ 64 - 0
scripts/collect-S4-public-images.py

@@ -0,0 +1,64 @@
+"""Save displayed public image URLs and create a review contact sheet."""
+import concurrent.futures
+from datetime import datetime, timezone
+import hashlib
+from io import BytesIO
+import json
+from pathlib import Path
+from urllib.request import Request, urlopen
+
+from PIL import Image, ImageDraw, ImageOps
+
+root = Path('work/S4-web-research-20261103')
+out = root / 'images'
+out.mkdir(exist_ok=False)
+jobs = []
+for source, prefix, count in [('post-label-review', 'post', 13), ('suning-photo-117', 'suning', 5)]:
+    capture = json.loads((root / (source + '.capture.json')).read_text(encoding='utf-8'))
+    for index, item in enumerate(capture['images'][:count], 1):
+        url = item.get('src') or item.get('data-src')
+        if url.startswith('//'):
+            url = 'https:' + url
+        jobs.append({'id': f'{prefix}-{index:02}', 'source': source, 'url': url})
+
+
+def fetch(job):
+    record = dict(job, collectedAt=datetime.now(timezone.utc).isoformat(), authenticated=False)
+    try:
+        request = Request(job['url'], headers={'User-Agent': 'Mozilla/5.0'})
+        with urlopen(request, timeout=22) as response:
+            data = response.read(12_000_001)
+            assert len(data) <= 12_000_000
+            with Image.open(BytesIO(data)) as picture:
+                picture.load()
+                fmt, dimensions = picture.format, picture.size
+            extension = {'JPEG': '.jpg', 'PNG': '.png', 'WEBP': '.webp'}.get(fmt)
+            assert extension, fmt
+            name = job['id'] + extension
+            with (out / name).open('xb') as stream:
+                stream.write(data)
+            record.update(status=response.status, finalUrl=response.url, file='images/' + name,
+                          bytes=len(data), sha256=hashlib.sha256(data).hexdigest(),
+                          dimensions=list(dimensions), format=fmt)
+    except Exception as error:
+        record.update(errorType=type(error).__name__, errorMessage=str(error)[:300])
+    return record
+
+
+with concurrent.futures.ThreadPoolExecutor(max_workers=4) as executor:
+    records = list(executor.map(fetch, jobs))
+with (out / 'index.json').open('x', encoding='utf-8') as stream:
+    json.dump(records, stream, ensure_ascii=False, indent=2)
+for prefix in ('post', 'suning'):
+    selected = [r for r in records if r['id'].startswith(prefix) and 'file' in r]
+    sheet = Image.new('RGB', (1200, ((len(selected) + 3) // 4) * 350), 'white')
+    draw = ImageDraw.Draw(sheet)
+    for index, record in enumerate(selected):
+        with Image.open(root / record['file']) as picture:
+            thumb = ImageOps.contain(picture.convert('RGB'), (290, 310))
+        x, y = index % 4 * 300, index // 4 * 350
+        sheet.paste(thumb, (x + (300 - thumb.width) // 2, y))
+        draw.text((x + 8, y + 315), record['id'] + ' ' + str(record['dimensions']), fill='black')
+    sheet.save(out / (prefix + '-contact-sheet.png'))
+for r in records:
+    print(json.dumps({k: r[k] for k in ('id', 'status', 'dimensions', 'bytes', 'errorType') if k in r}))

+ 86 - 0
scripts/collect-S4-public-pages.py

@@ -0,0 +1,86 @@
+"""Archive public, unauthenticated GET responses for S4 desk research only."""
+import concurrent.futures
+from datetime import datetime, timezone
+from html.parser import HTMLParser
+import hashlib
+import json
+from pathlib import Path
+import re
+import sys
+from urllib.error import HTTPError
+from urllib.request import Request, urlopen
+
+class Page(HTMLParser):
+    def __init__(self):
+        super().__init__(convert_charrefs=True)
+        self.skip = 0
+        self.title = False
+        self.parts, self.titles, self.links, self.images = [], [], [], []
+    def handle_starttag(self, tag, attrs):
+        attrs = dict(attrs)
+        if tag in ('script', 'style', 'noscript'):
+            self.skip += 1
+        if tag == 'title':
+            self.title = True
+        if tag == 'a' and attrs.get('href'):
+            self.links.append(attrs['href'])
+        if tag == 'img':
+            self.images.append({k: v for k, v in attrs.items() if k in ('src', 'data-src', 'srcset', 'alt')})
+    def handle_endtag(self, tag):
+        if tag in ('script', 'style', 'noscript'):
+            self.skip = max(0, self.skip - 1)
+        if tag == 'title':
+            self.title = False
+    def handle_data(self, value):
+        if self.title:
+            self.titles.append(value)
+        if not self.skip and value.strip():
+            self.parts.append(value.strip())
+
+out = Path(sys.argv[1])
+jobs = json.loads(Path(sys.argv[2]).read_text(encoding='utf-8'))
+out.mkdir(parents=True, exist_ok=True)
+
+def capture(job):
+    name, url = job['id'], job['url']
+    assert re.fullmatch(r'[a-zA-Z0-9_-]+', name)
+    for ext in ('.response.bin', '.html', '.text.txt', '.capture.json'):
+        assert not (out / (name + ext)).exists(), 'Preserve earlier capture'
+    started = datetime.now(timezone.utc).isoformat()
+    record = {'id': name, 'requestedUrl': url, 'startedAtUtc': started, 'method': 'GET',
+              'authenticated': False, 'proofScope': 'Public page observation, not physical verification', 'businessWrites': 0}
+    try:
+        request = Request(url, headers={'User-Agent': 'Mozilla/5.0', 'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.7'})
+        try:
+            response = urlopen(request, timeout=22)
+        except HTTPError as error:
+            response = error
+        with response:
+            body = response.read(8_000_001)
+            assert len(body) <= 8_000_000, 'Response exceeds research archive limit'
+            content_type = response.headers.get('Content-Type', '')
+            encoding = response.headers.get_content_charset() or 'utf-8'
+            if 'charset=' not in content_type.lower():
+                meta = re.search(br'charset\s*=\s*["\x27]?([\w-]+)', body[:6000], re.I)
+                if meta:
+                    encoding = meta.group(1).decode('ascii')
+            html = body.decode(encoding, errors='replace')
+            page = Page()
+            page.feed(html)
+            text = '\n'.join(page.parts)
+            for suffix, data in [('.response.bin', body), ('.html', html.encode('utf-8')), ('.text.txt', text.encode('utf-8'))]:
+                with (out / (name + suffix)).open('xb') as stream:
+                    stream.write(data)
+            record.update(status=response.status, finalUrl=response.url, contentType=content_type,
+                          encoding=encoding, bytes=len(body), responseSha256=hashlib.sha256(body).hexdigest(),
+                          title=''.join(page.titles), textCharacters=len(text), links=page.links, images=page.images)
+    except Exception as error:
+        record.update(errorType=type(error).__name__, errorMessage=str(error)[:300])
+    record['finishedAtUtc'] = datetime.now(timezone.utc).isoformat()
+    with (out / (name + '.capture.json')).open('x', encoding='utf-8') as stream:
+        json.dump(record, stream, ensure_ascii=False, indent=2)
+    return {key: record[key] for key in ('id', 'status', 'title', 'textCharacters', 'errorType') if key in record}
+
+with concurrent.futures.ThreadPoolExecutor(max_workers=4) as executor:
+    for result in executor.map(capture, jobs):
+        print(json.dumps(result, ensure_ascii=True), flush=True)

+ 83 - 0
scripts/collect-first-live-sources.py

@@ -0,0 +1,83 @@
+"""Capture two official noodle pages and build a bounded, source-backed import batch."""
+from pathlib import Path
+from html.parser import HTMLParser
+from datetime import datetime, timezone
+from urllib.request import Request, urlopen
+import hashlib
+import json
+import re
+
+ROOT = Path(__file__).resolve().parents[1]
+OUT = ROOT / 'work/first-live-sources-20261102'
+TASK_KEY = 'KSF-真实资料-20261102-01'
+
+class Text(HTMLParser):
+    def __init__(self):
+        super().__init__(convert_charrefs=True)
+        self.skip = 0
+        self.parts = []
+    def handle_starttag(self, tag, attrs):
+        if tag in ('script', 'style'):
+            self.skip += 1
+    def handle_endtag(self, tag):
+        if tag in ('script', 'style'):
+            self.skip = max(0, self.skip - 1)
+    def handle_data(self, data):
+        if not self.skip:
+            self.parts.append(data)
+
+def dump(file, value):
+    file.write_text(json.dumps(value, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
+
+def main():
+    OUT.mkdir(parents=True, exist_ok=False)
+    category = {'dataMode': 'live', 'code': 'KSF-RESEARCH-INSTANT-MIXED-NOODLES-V1', 'name': '速食拌面(研究工作分类)', 'taxonomyVersion': 'ksf-research-food-form-v1', 'axis': '食用方式', 'scopeNote': '研究者按官网明确标为拌面/酱拌面的产品设定工作分类,不是官方行业分类;本批不含汤面、传统现制面或未知食用方式产品。'}
+    specs = [
+        {'key': 'uni-jiangban', 'url': 'https://www.uni-president.com.cn/products-jiangbanmian.asp', 'title': '统一官网:统一酱拌面', 'family': '统一酱拌面', 'marker': '统一酱拌面 酱浓才够味', 'flavors': ['老干妈豆豉辣酱风味', '卤香牛肉风味', '老坛酸菜牛肉风味', 'XO酱海鲜风味'], 'packaging': '碗、五合一(袋*5)'},
+        {'key': 'uni-qiaomianguan', 'url': 'https://www.uni-president.com.cn/products-qiaomianguan.asp', 'title': '统一官网:巧面馆(仅拌面部分)', 'family': '统一巧面馆拌面', 'marker': '统一巧面馆(拌面)', 'flavors': ['老坛酸菜酱拌面', '老成都担担面', '重庆豌豆杂酱风味面', '串串香干碟拌面', '贵州豆豉辣酱风味面'], 'packaging': '碗、袋、五合一(袋*5)'}
+    ]
+    batches = []
+    facts = []
+    for spec in specs:
+        with urlopen(Request(spec['url'], headers={'User-Agent': 'KSF-Workshop-Research/1.0'}), timeout=25) as response:
+            assert response.status == 200
+            assert response.url == spec['url'], 'unexpected redirect'
+            raw = response.read(2_000_001)
+            assert len(raw) <= 2_000_000
+            content_type = response.headers.get('Content-Type', '')
+        collected = datetime.now(timezone.utc).isoformat(timespec='milliseconds').replace('+00:00', 'Z')
+        html = raw.decode('gb18030', errors='strict')
+        parser = Text()
+        parser.feed(html)
+        text = re.sub(r'\s+', ' ', ' '.join(parser.parts)).strip()
+        assert spec['marker'] in text
+        section = text[text.index(spec['marker']):]
+        quote_match = re.search(r'产品口味:.*?包装规格:[^ ]+', section)
+        assert quote_match, 'flavor/packaging block missing'
+        quote = quote_match.group(0)
+        assert all(flavor in quote for flavor in spec['flavors']), 'official flavor list changed'
+        assert 'sample' not in category.values()
+        (OUT / (spec['key'] + '.response.bin')).write_bytes(raw)
+        (OUT / (spec['key'] + '.html')).write_text(html, encoding='utf-8')
+        (OUT / (spec['key'] + '.txt')).write_text(text, encoding='utf-8')
+        source = {'title': spec['title'], 'sourceKind': 'web', 'sourceUrl': spec['url'], 'platform': 'official_website', 'dataMode': 'live', 'rawText': text, 'collectedAt': collected}
+        records = [{'itemKey': 'category', 'className': 'KfCategory', 'externalKey': 'KSF-RESEARCH-INSTANT-MIXED-NOODLES-V1', 'data': category}]
+        for index, flavor in enumerate(spec['flavors'], 1):
+            item = 'product-' + str(index)
+            name = spec['family'] + '·' + flavor
+            product = {'dataMode': 'live', 'name': name, 'brandName': '统一', 'categoryRef': {'$item': 'category'}, 'flavor': flavor, 'packaging': spec['packaging'], 'isOwnBrand': False, 'productLevel': 'product', 'attributes': {'sourceStatement': '官网品牌自述;不等同于在售或第三方验证', 'productGranularity': '口味条目,不展开为SKU,不假定各口味拥有全部包装组合', 'pagePublicationDate': None}}
+            records.append({'itemKey': item, 'className': 'KfProduct', 'externalKey': TASK_KEY + ':' + spec['key'] + ':' + item, 'dependsOn': ['category'], 'data': product})
+            records.append({'itemKey': 'evidence-' + str(index), 'className': 'KfEvidence', 'externalKey': TASK_KEY + ':' + spec['key'] + ':evidence-' + str(index), 'dependsOn': [item], 'data': {'dataMode': 'live', 'productRef': {'$item': item}, 'quote': quote, 'locator': {'url': spec['url'], 'section': spec['marker'], 'startCharacter': text.index(quote), 'endCharacter': text.index(quote) + len(quote), 'unit': 'Unicode code points; captured text UTF-8'}, 'evidenceKind': 'manufacturer_product_statement', 'collectedAt': collected, 'verificationState': 'page_fetched_claim_not_independently_verified', 'platform': 'official_website', 'quoteForm': 'verbatim_excerpt'}})
+            facts.append({'name': name, 'flavor': flavor, 'packagingStatement': spec['packaging'], 'sourceUrl': spec['url']})
+        task = {'source': source, 'records': records}
+        dump(OUT / (spec['key'] + '.import.json'), task)
+        capture = {'url': spec['url'], 'status': 200, 'contentType': content_type, 'encoding': 'gb18030', 'collectedAt': collected, 'responseSha256': hashlib.sha256(raw).hexdigest(), 'rawTextSha256': hashlib.sha256(text.encode()).hexdigest(), 'collector': 'Codex local urllib HTTPS GET; archived snapshot imported by workshop Agent', 'publishedAt': None}
+        dump(OUT / (spec['key'] + '.capture.json'), capture)
+        batches.append({'key': spec['key'], 'task': task, 'capture': capture})
+    gaps = ['康师傅自有拌面同口径产品资料尚待补齐,暂不能作完整自有/竞品比较', '官网页面无明确发布日期,不能据此判断新品上市时间或当前在售状态', '缺单个SKU净含量、配料表、营养信息及每个口味对应包装组合', '缺渠道/店铺、实际成交或观察价格、优惠口径与价格观察时间', '缺真实消费者逐字评价、使用场景与需求强度,不从品牌文案推断用户痛点', '缺供应商报价、配方比例、工艺参数、成本与法规验证,不能推出量产结论']
+    bundle = {'version': 'ksf.first-live-source-batch.v1', 'taskKey': TASK_KEY, 'batches': batches, 'facts': facts, 'pendingQuestions': gaps, 'collectionPerformedBy': 'Codex', 'platformScope': 'two publicly accessible official website pages; no ecommerce/social collection'}
+    dump(OUT / 'batch.json', bundle)
+    print(json.dumps({'ok': True, 'output': str(OUT), 'sourcePages': len(batches), 'flavorEntries': len(facts), 'evidenceEntries': len(facts), 'databaseWrites': 0}, ensure_ascii=False))
+
+if __name__ == '__main__':
+    main()

+ 50 - 0
scripts/diagnose-category-import-20261102.mjs

@@ -0,0 +1,50 @@
+// Read-only reconciliation of the stopped real-material import. Never retries writes.
+import fs from 'node:fs';
+import crypto from 'node:crypto';
+import {request,classRows} from '../server/scripts/platform-client.mjs';
+
+const sourceId='McdwhD7KQq';
+const categoryCode='KSF-RESEARCH-INSTANT-MIXED-NOODLES-V1';
+const sha=value=>crypto.createHash('sha256').update(value).digest('hex');
+const ptr=(className,objectId)=>({__type:'Pointer',className,objectId});
+const fields=(row,keys)=>Object.fromEntries(keys.filter(k=>row?.[k]!==undefined).map(k=>[k,row[k]]));
+try{
+ const results=await Promise.allSettled([
+  request('GET','/classes/KfSource/'+sourceId),
+  classRows('KfCategory',{code:categoryCode}),
+  classRows('KfImportJob',{inputSourceRef:ptr('KfSource',sourceId)}),
+  classRows('KfProduct',{sourceRef:ptr('KfSource',sourceId)}),
+  classRows('KfEvidence',{sourceRef:ptr('KfSource',sourceId)}),
+  request('GET','/schemas/KfCategory'),
+  classRows('Function',{name:{$in:['ksfCheckDuplicates','ksfRegisterSource','ksfValidateImport','ksfSubmitImport']}}),
+ ]);
+ if(results.some(r=>r.status!=='fulfilled'))throw Error('READ_FAILED');
+ const [source,categories,jobs,products,evidence,schema,functions]=results.map(r=>r.value);
+ const jobItems=[];
+ for(const job of jobs){
+  jobItems.push(...(await classRows('KfImportItem',{jobRef:ptr('KfImportJob',job.objectId)})));
+ }
+ const sourceRequestId=source.meta?.requestId;
+ const sourceOperations=sourceRequestId?await classRows('KsfOperation',{operation:'source',requestId:sourceRequestId}):[];
+ const report={
+  ok:true,operation:'stopped-category-import-readonly-diagnosis',at:new Date().toISOString(),
+  readOnly:true,businessWrites:0,functionWrites:0,
+  source:{...fields(source,['objectId','title','sourceKind','sourceUrl','platform','dataMode','rawText','contentHash','collectedAt','createdAt','externalKey','recordState']),
+   contentHashMatches:typeof source.rawText==='string'&&sha(source.rawText)===source.contentHash,
+   requestId:sourceRequestId,hasOwner:Boolean(source.meta?.ownerId)},
+  categories,
+  jobs:jobs.map(j=>fields(j,['objectId','jobKey','status','expectedCount','successCount','failureCount','createdAt','summary','inputSourceRef'])),
+  items:jobItems.map(i=>fields(i,['objectId','jobRef','itemKey','targetClass','targetId','businessKey','status','operation','createdAt'])),
+  products:products.map(p=>fields(p,['objectId','name','categoryRef','sourceRef','externalKey','createdAt'])),
+  evidence:evidence.map(e=>fields(e,['objectId','quote','sourceRef','externalKey','createdAt'])),
+  categorySchema:schema,
+  deployedFunctions:functions.map(f=>({objectId:f.objectId,name:f.name,sha256:sha(f.code),updatedAt:f.updatedAt})),
+  sourceOperationCount:sourceOperations.length,
+  sourceOperations:sourceOperations.map(o=>({objectId:o.objectId,requestId:o.requestId,payloadHash:o.payloadHash,receipt:o.receipt})),
+ };
+ fs.writeFileSync('work/category-import-diagnosis-20261102.json',JSON.stringify(report,null,2));
+ console.log(JSON.stringify(report,null,2));
+}catch{
+ console.error(JSON.stringify({ok:false,error:'CATEGORY_DIAGNOSIS_STOPPED',readOnly:true,businessWrites:0,message:'只读核对未完成;未输出原始异常或配置。'}));
+ process.exitCode=1;
+}

+ 66 - 0
scripts/finalize-board-handoff.py

@@ -0,0 +1,66 @@
+"""Seal the reviewed board handoff, validate its hashes, and emit a delivery receipt."""
+import hashlib
+import json
+from pathlib import Path
+import re
+import shutil
+import zipfile
+
+ROOT = Path(__file__).resolve().parents[1]
+BASE = ROOT / 'work/board-deploy-20261106-r1'
+PKG = BASE / 'package'
+OUT = ROOT / 'artifacts/看板部署-20261106'
+sha = lambda b: hashlib.sha256(b).hexdigest()
+
+def finalize():
+    OUT.mkdir(parents=True, exist_ok=True)
+    dest = OUT / 'ksf-board-deploy-20261106-r1.zip'
+    if dest.exists():
+        raise RuntimeError('RELEASE_ALREADY_SEALED')
+    doc = ROOT / 'docs/board-deploy-20261106/发给运维001-部署已推送看板.md'
+    shutil.copyfile(doc, PKG / doc.name)
+    manifest = json.loads((PKG / 'deploy/board-files.json').read_text(encoding='utf-8'))
+    closure = []
+    for f in manifest['files']:
+        p = PKG / 'payload' / f['path']
+        assert sha(p.read_bytes()) == f['sha256'], f['path']
+        if p.suffix == '.js':
+            for m in re.finditer(r'''(?:from|import)\s*\(?\s*['"](\.[^'"]+)['"]''', p.read_text(encoding='utf-8')):
+                imported = (p.parent / m[1]).resolve()
+                assert imported.is_relative_to((PKG / 'payload/src').resolve()) and imported.is_file(), (p, m[1])
+                closure.append(str(imported.relative_to(PKG)))
+    assert not (PKG / 'payload/index.html').exists()
+    assert not (PKG / 'payload/src/dev').exists()
+    assert not list(PKG.rglob('*.sqlite'))
+    assert not list(PKG.rglob('*.pyc'))
+    assert not list(PKG.rglob('.env*'))
+    publisher = json.loads((BASE / 'static-publisher-validation.json').read_text())
+    assert publisher['ok']
+    validation = {'ok': True, 'sourceCommit': manifest['commit'], 'staticFilesVerified': 71,
+        'relativeImportsChecked': len(closure), 'nodeTests': {'total': 83, 'passed': 83, 'failed': 0},
+        'pythonTests': {'total': 50, 'passed': 50, 'failed': 0, 'skipped': 0, 'usesPrivateCorpus': True},
+        'publisher': publisher, 'productionWrites': 0, 'productionBrowserVerified': False}
+    (PKG / 'VALIDATION.json').write_text(json.dumps(validation, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
+    files = sorted(p for p in PKG.rglob('*') if p.is_file() and p.name != 'FILES.sha256')
+    lines = [sha(p.read_bytes()) + '  ' + p.relative_to(PKG).as_posix() for p in files]
+    (PKG / 'FILES.sha256').write_text('\n'.join(lines) + '\n', encoding='utf-8', newline='\n')
+    with zipfile.ZipFile(dest, 'x', zipfile.ZIP_DEFLATED, compresslevel=9) as z:
+        for p in [*files, PKG / 'FILES.sha256']:
+            z.write(p, p.relative_to(PKG).as_posix())
+    with zipfile.ZipFile(dest) as z:
+        assert z.testzip() is None
+        for line in z.read('FILES.sha256').decode('utf-8').splitlines():
+            digest, p = line.split('  ', 1)
+            assert sha(z.read(p)) == digest, p
+        assert len(z.namelist()) == len(files) + 1
+    release = json.loads((PKG / 'RELEASE.json').read_text(encoding='utf-8'))
+    receipt = {'ok': True, 'file': dest.name, 'bytes': dest.stat().st_size, 'sha256': sha(dest.read_bytes()),
+        'entries': len(files) + 1, 'filesVerified': len(files), 'release': release['release'], 'commit': release['commit'],
+        'requiresDatabasePackage': release['requiredDatabasePackage'], 'productionInstalled': False,
+        'productionWrites': 0, 'browserVerified': False, 'validation': validation}
+    (OUT / 'delivery-receipt.json').write_text(json.dumps(receipt, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
+    shutil.copyfile(doc, OUT / doc.name)
+    print(json.dumps(receipt, ensure_ascii=False))
+
+if __name__ == '__main__':
+    finalize()

+ 88 - 0
scripts/inspect-S4-browser-display.mjs

@@ -0,0 +1,88 @@
+// Fresh anonymous production browser. Public reads only; no application overrides.
+import fs from 'node:fs';
+import path from 'node:path';
+import assert from 'node:assert/strict';
+import {spawn} from 'node:child_process';
+const verified=JSON.parse(fs.readFileSync('work/live-S4-readback-20261103.json','utf8'));
+const template=JSON.parse(fs.readFileSync('work/S4-classroom-next-20261103/05-project-S4-save.template.json','utf8'));
+assert.ok(verified.ok);assert.equal(verified.artifactId,'U3_W7WDliP');
+const base='https://kangshifu.fmode.cn',output='work/live-S4-display-inspection-20261103.json',runId=Date.now();
+assert.equal(fs.existsSync(output),false,'Preserve earlier browser acceptance');
+const profile=path.resolve('work/browser-S4-'+runId);fs.mkdirSync(profile);
+const browser=spawn('C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe',[
+ '--headless=new','--disable-gpu','--no-first-run','--no-default-browser-check','--disable-background-networking',
+ '--remote-debugging-address=127.0.0.1','--remote-debugging-port=0','--user-data-dir='+profile,'about:blank'
+],{windowsHide:true,stdio:'ignore'});
+const sleep=ms=>new Promise(resolve=>setTimeout(resolve,ms));
+const checks=[],errors=[],requests=[],observations=[],screenshots=[];
+let socket;
+try{
+ let port;
+ for(let i=0;i<100;i++){try{port=Number(fs.readFileSync(path.join(profile,'DevToolsActivePort'),'utf8').split('\n')[0]);}catch{}if(port)break;await sleep(100);}
+ assert.ok(port,'browser debugger');
+ const tabs=await(await fetch('http://127.0.0.1:'+port+'/json/list')).json();
+ socket=new WebSocket(tabs.find(t=>t.type==='page').webSocketDebuggerUrl);
+ await new Promise((resolve,reject)=>{socket.addEventListener('open',resolve,{once:true});socket.addEventListener('error',reject,{once:true});});
+ let seq=0;const pending=new Map();
+ socket.addEventListener('message',({data})=>{
+  const m=JSON.parse(data);
+  if(m.id&&pending.has(m.id)){const p=pending.get(m.id);clearTimeout(p.timer);pending.delete(m.id);m.error?p.reject(Error(m.error.message)):p.resolve(m.result);}
+  if(m.method==='Runtime.exceptionThrown')errors.push({runtime:m.params.exceptionDetails.text});
+  if(m.method==='Network.loadingFailed'&&m.params.errorText!=='net::ERR_ABORTED')errors.push({network:m.params.errorText});
+  if(m.method==='Log.entryAdded'&&m.params.entry.level==='error'&&!m.params.entry.url?.endsWith('/favicon.ico'))errors.push({console:m.params.entry.text});
+  if(m.method==='Network.requestWillBeSent'){
+   const r=m.params.request;let cloudPath=null;
+   if(r.url.endsWith('/api/functions')){try{cloudPath=JSON.parse(r.postData||'{}').path??null;}catch{}}
+   requests.push({url:r.url,method:r.method,headerNames:Object.keys(r.headers||{}),cloudPath});
+  }
+ });
+ const cdp=(method,params={})=>new Promise((resolve,reject)=>{
+  const id=++seq,timer=setTimeout(()=>{pending.delete(id);reject(Error(method+' timeout'));},30000);
+  pending.set(id,{resolve,reject,timer});socket.send(JSON.stringify({id,method,params}));
+ });
+ const evaluate=async expression=>{
+  const r=await cdp('Runtime.evaluate',{expression,returnByValue:true,awaitPromise:true});
+  if(r.exceptionDetails)throw Error(r.exceptionDetails.text);return r.result.value;
+ };
+ const check=(name,passed)=>{checks.push({name,passed:Boolean(passed)});assert.ok(passed,name);};
+ const helpers=`function find(root,selector){const hit=root.querySelector(selector);if(hit)return hit;for(const n of root.querySelectorAll('*'))if(n.shadowRoot){const found=find(n.shadowRoot,selector);if(found)return found;}return null;}
+ function text(root){let out='';for(const n of root.childNodes){if(n.nodeType===Node.TEXT_NODE)out+=n.textContent;else if(n.nodeType===Node.ELEMENT_NODE&&!['STYLE','SCRIPT','NOSCRIPT','TEMPLATE'].includes(n.tagName)){out+=text(n);if(n.shadowRoot)out+=text(n.shadowRoot);}}return out;}
+ function tableSection(title){const view=find(document,'view-report');return [...view.shadowRoot.querySelectorAll('section.sec')].find(s=>s.querySelector('h4')?.textContent.trim()===title);}`;
+ const deepText=()=>evaluate(`(()=>{${helpers}return text(document);})()`);
+ const compact=x=>x.replace(/\s/g,'');
+ const displayText=x=>x.replace(/\u2009/g,'');
+ const screenshot=async label=>{
+  const shot=await cdp('Page.captureScreenshot',{format:'png'}),file='work/live-S4-'+label+'-'+runId+'.png';
+  fs.writeFileSync(file,Buffer.from(shot.data,'base64'),{flag:'wx'});screenshots.push(file);
+ };
+ const navigate=async route=>{
+  await cdp('Page.navigate',{url:base+route});let ready=false;
+  for(let i=0;i<120;i++){await sleep(200);try{ready=await evaluate("import('/src/services/board-service.js').then(m=>m.boardService.status.get()==='ready')");}catch{}if(ready)break;}
+  check(route+' public data loaded',ready);await sleep(250);
+ };
+ const click=async point=>{
+  await cdp('Input.dispatchMouseEvent',{type:'mousePressed',...point,button:'left',clickCount:1});
+  await cdp('Input.dispatchMouseEvent',{type:'mouseReleased',...point,button:'left',clickCount:1});
+ };
+ await cdp('Page.enable');await cdp('Runtime.enable');await cdp('Network.enable');await cdp('Log.enable');
+
+ await cdp('Emulation.setDeviceMetricsOverride',{width:390,height:844,deviceScaleFactor:1,mobile:true});
+ await navigate('/report/'+verified.artifactId);
+ await evaluate(`(()=>{${helpers}find(document,'[data-anchor="public-p1"]').scrollIntoView({block:'center'});})()`);
+ await sleep(250);
+ const point=await evaluate(`(()=>{${helpers}const r=find(document,'[data-anchor="public-p1"]').getBoundingClientRect();return {x:r.left+r.width/2,y:r.top+r.height/2};})()`);
+ await click(point);await sleep(350);
+ const display=await evaluate(`(()=>{${helpers}const host=find(document,'kf-drawer'),body=host.shadowRoot.querySelector('.body'),prov=body.querySelector('.prov'),metrics=body.querySelector('.kv');return {open:host.open,metricLabels:[...metrics.querySelectorAll('div')].map(n=>n.textContent),sourceRef:prov.textContent,body:{clientWidth:body.clientWidth,scrollWidth:body.scrollWidth,overflowX:getComputedStyle(body).overflowX},page:{clientWidth:document.documentElement.clientWidth,scrollWidth:document.documentElement.scrollWidth}};})()`);
+ const publicModel=await evaluate("import('/src/services/board-service.js').then(({boardService})=>({ownerCount:boardService.db.get().artifactById['U3_W7WDliP'].report.evidence['public-p1'].ownerCount??null}))");
+ check('owner count remains unknown in public evidence model',publicModel.ownerCount===null);
+ check('unknown owner count shown as dash, not numeric one',display.metricLabels[0].replace(/\s/g,'')==='—人独立提到');
+ check('P1 drawer open',display.open);
+ const scroll=await evaluate(`(()=>{${helpers}const b=find(document,'kf-drawer').shadowRoot.querySelector('.body');b.scrollLeft=b.scrollWidth;return {scrollLeft:b.scrollLeft,maxScroll:b.scrollWidth-b.clientWidth};})()`);
+ await screenshot('source-ref-display-mobile');
+ const result={ok:true,at:new Date().toISOString(),operation:'S4-targeted-display-inspection',readOnly:true,businessWrites:0,checks,display,publicModel,horizontalScroll:scroll,screenshots,
+ notes:['The glyph was a dash for unknown ownerCount, not a count of one. Generic person wording remains inappropriate for a retail listing.','A long provenance URL makes the drawer body horizontally scrollable on 390px; page width remains 390px.']};
+ fs.writeFileSync(output,JSON.stringify(result,null,2)+'\n',{flag:'wx'});console.log(JSON.stringify(result,null,2));
+}catch(error){
+ const result={ok:false,at:new Date().toISOString(),error:error.message,checks,errors,readOnly:true,businessWrites:0};
+ fs.writeFileSync(output,JSON.stringify(result,null,2)+'\n',{flag:'wx'});console.error(JSON.stringify(result));process.exitCode=1;
+}finally{socket?.close();browser.kill();}

+ 20 - 0
scripts/inspect-S4-public-records.mjs

@@ -0,0 +1,20 @@
+// Read only: four exact ksfGetRecord calls; no import/save/update or retries.
+import fs from 'node:fs';
+import assert from 'node:assert/strict';
+import {invoke} from '../server/scripts/platform-client.mjs';
+
+const output='work/S4-public-record-inspection-20261103.json';
+assert.equal(fs.existsSync(output),false,'Preserve existing inspection');
+const targets=[['KfSource','PjORcvaRHn'],['KfEvidence','xHGV_mRWcG'],['KfSource','5S_Y4IgT5q'],['KfEvidence','CeAEIwymTa']];
+const keep=['objectId','title','sourceKind','sourceUrl','platform','rawText','collectedAt','contentHash','dataMode','recordState','reviewState','schemaVersion','externalKey','quote','locator','evidenceKind','verificationState','quoteForm','authorLabel','parentObjectId','publishedAt','productRef','artifactRef','sourceRef'];
+const settled=await Promise.allSettled(targets.map(async([className,objectId])=>{
+ const response=await invoke('ksfGetRecord',{className,objectId});
+ assert.equal(response.http,200);assert.equal(response.body?.result?.ok,true);
+ const row=response.body.result.data.record;assert.equal(row?.objectId,objectId);
+ return{className,objectId,requestId:response.body.result.requestId??null,record:Object.fromEntries(keep.filter(key=>Object.hasOwn(row,key)).map(key=>[key,row[key]]))};
+}));
+const failures=settled.flatMap((r,i)=>r.status==='rejected'?[targets[i]]:[]);
+const result={ok:failures.length===0,at:new Date().toISOString(),readOnly:true,businessWrites:0,readFunctionCalls:4,failures,records:settled.filter(r=>r.status==='fulfilled').map(r=>r.value)};
+fs.writeFileSync(output,JSON.stringify(result,null,2)+'\n',{flag:'wx'});
+console.log(JSON.stringify({ok:result.ok,readFunctionCalls:4,failures,records:result.records.map(({className,objectId,record})=>({className,objectId,characters:record.rawText?[...record.rawText].length:undefined,evidenceKind:record.evidenceKind,verificationState:record.verificationState,quoteForm:record.quoteForm,authorLabel:record.authorLabel,publishedAt:record.publishedAt,locatorKeys:record.locator?Object.keys(record.locator):undefined}))},null,2));
+if(failures.length)process.exitCode=1;

+ 15 - 0
scripts/inspect-workshop-data.mjs

@@ -0,0 +1,15 @@
+import fs from 'node:fs';
+import {request} from '../server/scripts/platform-client.mjs';
+const schemas=(await request('GET','/schemas')).results;
+const names=schemas.map(x=>x.className).filter(x=>/user|agent|employee|session|member/i.test(x));
+const inspect=['_User','KfSession','KfArtifact','KfProject','KfOpportunity'];
+const rows=await Promise.allSettled(inspect.map(async className=>{
+ const schema=schemas.find(x=>x.className===className);
+ const keys=['objectId','username','name','nickname','displayName','studentName','ownerName','ownerSession','uploadedBy','uploadedByKey','agentId','agentName','sourceSession','uploadMethod','uploadedAt','stage','stageCode','projectId','projectRef','sessionRef','userRef','groupId','workshopKey','externalKey','dataMode','reportType','artifactType','meta'].filter(x=>schema?.fields[x]);
+ const result=await request('GET','/classes/'+className+'?'+new URLSearchParams({keys:keys.join(','),limit:'1000'}));
+ const safe=(result.results||[]).map(({meta,...x})=>({...x,...(meta?.attribution?{attribution:meta.attribution}:{})}));
+ return{className,fields:Object.keys(schema?.fields||{}),returnedRows:safe.length,examples:safe.slice(0,className==='_User'?20:4),agents:[...new Set(safe.map(x=>x.agentName).filter(Boolean))],owners:[...new Set(safe.map(x=>x.ownerName||x.studentName).filter(Boolean))]};
+}));
+const out={at:new Date().toISOString(),businessWrites:0,identityRelatedClasses:names,classes:rows.map((r,i)=>r.status==='fulfilled'?r.value:{className:inspect[i],error:'READ_FAILED'})};
+fs.mkdirSync('work',{recursive:true});fs.writeFileSync('work/workshop-data-before.json',JSON.stringify(out,null,2));
+console.log(JSON.stringify(out,null,2));

+ 156 - 0
scripts/install-query-skills.py

@@ -0,0 +1,156 @@
+"""Install the two query skills from a verified package; default is a read-only plan.
+
+Only checksummed skill files are managed. Credentials, employee state, databases,
+gateway configuration and services are never installed or modified here.
+"""
+import argparse
+import hashlib
+import json
+import os
+from pathlib import Path
+import shutil
+import tempfile
+
+
+NAMES = ('ksf-workshop-runtime', 'ksf-voc-library')
+
+
+def digest(path):
+    with path.open('rb') as stream:
+        return hashlib.file_digest(stream, 'sha256').hexdigest()
+
+
+def plain(path):
+    path = Path(path).absolute()
+    for ancestor in (path, *path.parents):
+        if ancestor.exists() or ancestor.is_symlink():
+            if ancestor.is_symlink() or ancestor.resolve() != ancestor:
+                raise ValueError('REDIRECTED_PATH')
+    return path
+
+
+def snapshot(folder):
+    if not folder.exists():
+        return {}
+    result = {}
+    for path in folder.rglob('*'):
+        plain(path)
+        if path.is_file():
+            result[path.relative_to(folder).as_posix()] = digest(path)
+    return result
+
+
+def contents(folder):
+    plain(folder)
+    entries = {}
+    for line in (folder / 'FILES.sha256').read_text(encoding='utf-8').splitlines():
+        sha, rel = line.split('  ', 1)
+        parts = Path(rel).parts
+        if len(sha) != 64 or any(ch not in '0123456789abcdef' for ch in sha) or not parts or Path(rel).is_absolute() or '..' in parts or rel in entries or rel in ('FILES.sha256', 'credential-binding.json'):
+            raise ValueError('INVALID_SKILL_MANIFEST')
+        if digest(plain(folder / rel)) != sha:
+            raise ValueError('PACKAGE_HASH_MISMATCH')
+        entries[rel] = sha
+    entries['FILES.sha256'] = digest(folder / 'FILES.sha256')
+    if snapshot(folder) != entries:
+        raise ValueError('UNEXPECTED_SKILL_FILES')
+    return entries
+
+
+def install(package, data_root, apply=False):
+    package, data_root = plain(package), plain(data_root)
+    if package == data_root or package in data_root.parents:
+        raise ValueError('NESTED_PACKAGE_AND_DATA_ROOT')
+    baseline = json.loads((package / 'baselines.json').read_text(encoding='utf-8'))
+    entries, changes, targets = {}, [], {}
+    for name in NAMES:
+        source = package / 'skills' / name
+        target = plain(data_root / 'skills' / name)
+        if package == target or package in target.parents or target in package.parents:
+            raise ValueError('NESTED_PACKAGE_AND_SKILL')
+        wanted = contents(source)
+        entries[name], targets[name] = wanted, target
+        if target.exists() and not target.is_dir():
+            raise ValueError('TARGET_NOT_DIRECTORY')
+        actual = {rel: digest(plain(target / rel)) for rel in wanted if (target / rel).is_file()}
+        if target.exists() and actual != wanted and actual not in baseline[name]:
+            raise ValueError('UNKNOWN_SKILL_BASELINE:' + name)
+        if not target.exists() and name == 'ksf-workshop-runtime':
+            raise ValueError('WORKSHOP_INSTANCE_NOT_PROVISIONED')
+        for rel, sha in wanted.items():
+            if actual.get(rel) != sha:
+                changes.append((name, rel, actual.get(rel), sha))
+    state = data_root / 'ksf-workshop-state'
+    binding = targets['ksf-workshop-runtime'] / 'credential-binding.json'
+    before_state = snapshot(state)
+    before_binding = digest(plain(binding)) if binding.is_file() else None
+    result = dict(ok=True, readOnly=not apply, applied=False,
+                  changed=[name + '/' + rel for name, rel, _, _ in changes],
+                  filesChecked=sum(len(files) for files in entries.values()),
+                  databaseWrites=0, databaseChecked=False, networkRequests=0,
+                  credentialBindingPreserved=True, stateDirectoryPreserved=True,
+                  gatewayModified=False, discoveryVerified=False, realMessageVerified=False)
+    if not apply or not changes:
+        return result
+    data_root.mkdir(parents=True, exist_ok=True)
+    backup = Path(tempfile.mkdtemp(prefix='ksf-query-skills-backup-', dir=data_root))
+    os.chmod(backup, 0o700)
+    installed = []
+    try:
+        for name, rel, before, after in changes:
+            source, target = package / 'skills' / name / rel, targets[name] / rel
+            if (digest(plain(target)) if target.is_file() else None) != before:
+                raise ValueError('CONCURRENT_SKILL_CHANGE')
+            mode = target.stat().st_mode & 0o777 if target.is_file() else 0o600
+            previous = backup / name / rel
+            if before:
+                previous.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
+                shutil.copy2(target, previous)
+            target.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
+            fd, temporary = tempfile.mkstemp(prefix='.query-skill-', dir=target.parent)
+            temporary = Path(temporary)
+            try:
+                with os.fdopen(fd, 'wb') as stream:
+                    stream.write(source.read_bytes())
+                    stream.flush()
+                    os.fsync(stream.fileno())
+                os.chmod(temporary, mode)
+                if digest(temporary) != after:
+                    raise ValueError('STAGED_HASH_MISMATCH')
+                if (digest(plain(target)) if target.is_file() else None) != before:
+                    raise ValueError('CONCURRENT_SKILL_CHANGE')
+                os.replace(temporary, target)
+                installed.append((target, previous, before, after))
+            finally:
+                temporary.unlink(missing_ok=True)
+        for name, wanted in entries.items():
+            if any(digest(plain(targets[name] / rel)) != sha for rel, sha in wanted.items()):
+                raise ValueError('INSTALLED_HASH_MISMATCH')
+        if snapshot(state) != before_state or (digest(plain(binding)) if binding.is_file() else None) != before_binding:
+            raise ValueError('STATE_OR_BINDING_CHANGED')
+    except Exception:
+        for target, previous, before, after in reversed(installed):
+            # Never overwrite a concurrent edit while rolling back our files.
+            if target.is_file() and digest(target) == after:
+                if before:
+                    shutil.copy2(previous, target)
+                else:
+                    target.unlink()
+        raise
+    result.update(applied=True, backup=str(backup), filesystemVerified=True)
+    return result
+
+
+if __name__ == '__main__':
+    parser = argparse.ArgumentParser(description=__doc__)
+    parser.add_argument('--package', required=True)
+    parser.add_argument('--data-root', default='/opt/data')
+    mode = parser.add_mutually_exclusive_group()
+    mode.add_argument('--check', action='store_true')
+    mode.add_argument('--apply', action='store_true')
+    args = parser.parse_args()
+    try:
+        print(json.dumps(install(args.package, args.data_root, args.apply), ensure_ascii=False, indent=2))
+    except (ValueError, OSError, KeyError) as error:
+        print(json.dumps(dict(ok=False, error=str(error)), ensure_ascii=False))
+        raise SystemExit(1)

+ 144 - 0
scripts/install-voc-database.py

@@ -0,0 +1,144 @@
+"""Install only the private VOC database at the existing query skill's default path.
+
+Default is a read-only plan. Exact repeat is a no-op; a different existing target
+stops. No skill, environment, gateway, employee state or business record is changed.
+"""
+import argparse
+import contextlib
+import hashlib
+import json
+import os
+from pathlib import Path
+import re
+import shutil
+import sqlite3
+import sys
+import tempfile
+
+sys.dont_write_bytecode = True
+
+
+def digest(path):
+    with Path(path).open('rb') as stream:
+        return hashlib.file_digest(stream, 'sha256').hexdigest()
+
+
+def plain(path):
+    path = Path(path).absolute()
+    for parent in (path, *path.parents):
+        if parent.exists() or parent.is_symlink():
+            if parent.is_symlink() or parent.resolve() != parent:
+                raise ValueError('SYMLINK_OR_REDIRECTED_PATH')
+    return path
+
+
+def package_manifest(package):
+    entries = {}
+    for line in (package / 'FILES.sha256').read_text(encoding='utf-8').splitlines():
+        match = re.fullmatch(r'([a-f0-9]{64})  ([^\\]+)', line)
+        if not match:
+            raise ValueError('INVALID_PACKAGE_MANIFEST')
+        sha, name = match.groups()
+        rel = Path(name)
+        if rel.is_absolute() or '..' in rel.parts or name in entries or name == 'FILES.sha256':
+            raise ValueError('INVALID_PACKAGE_PATH')
+        path = plain(package / rel)
+        if not path.is_relative_to(package) or not path.is_file() or digest(path) != sha:
+            raise ValueError('PACKAGE_HASH_MISMATCH:' + name)
+        entries[name] = sha
+    actual = {p.relative_to(package).as_posix() for p in package.rglob('*') if p.is_file()}
+    for path in package.rglob('*'):
+        plain(path)
+    if actual != set(entries) | {'FILES.sha256'}:
+        raise ValueError('UNEXPECTED_PACKAGE_FILES')
+    release = json.loads((package / 'DATABASE.json').read_text(encoding='utf-8'))
+    if release['database']['sha256'] != entries.get('library/library.sqlite'):
+        raise ValueError('DATABASE_MANIFEST_MISMATCH')
+    return release, entries
+
+
+def verify_database(path, expected):
+    if digest(path) != expected['sha256'] or path.stat().st_size != expected['bytes']:
+        raise ValueError('DATABASE_HASH_MISMATCH')
+    with contextlib.closing(sqlite3.connect(path.as_uri() + '?mode=ro&immutable=1', uri=True)) as con:
+        con.execute('PRAGMA query_only=ON')
+        meta = dict(con.execute('SELECT key,value FROM meta'))
+        if meta.get('schema') != expected['schema'] or meta.get('status') != 'ready' or meta.get('generation') != expected['generation']:
+            raise ValueError('DATABASE_VERSION_OR_GENERATION_MISMATCH')
+        if meta.get('archive_sha256') != expected['originalArchiveSha256']:
+            raise ValueError('ORIGINAL_ARCHIVE_MISMATCH')
+        if con.execute('PRAGMA integrity_check').fetchone()[0] != 'ok' or con.execute('PRAGMA foreign_key_check').fetchall():
+            raise ValueError('DATABASE_INTEGRITY_FAILED')
+        counts = {}
+        for table, count in expected['counts'].items():
+            if not re.fullmatch('[a-z_]+', table):
+                raise ValueError('INVALID_TABLE_NAME')
+            counts[table] = con.execute('SELECT count(*) FROM ' + table).fetchone()[0]
+            if counts[table] != count:
+                raise ValueError('DATABASE_COUNT_MISMATCH:' + table)
+        con.execute('SELECT rowid FROM retrieval_fts WHERE retrieval_fts MATCH ? LIMIT 1', ('拌',)).fetchall()
+    return counts
+
+
+def install(package, target_dir, apply=False):
+    package, target_dir = plain(package), plain(target_dir)
+    if package == target_dir or package in target_dir.parents or target_dir in package.parents:
+        raise ValueError('NESTED_PACKAGE_AND_LIBRARY')
+    release, entries = package_manifest(package)
+    expected = release['database']
+    source = package / 'library/library.sqlite'
+    counts = verify_database(source, expected)
+    destination = target_dir / 'library.sqlite'
+    if target_dir.exists():
+        if not target_dir.is_dir() or {p.name for p in target_dir.iterdir()} != {'library.sqlite'} or not destination.is_file():
+            raise ValueError('TARGET_EXISTS_DIFFERENT_CONTENT')
+        plain(destination)
+        if digest(destination) != expected['sha256']:
+            raise ValueError('TARGET_EXISTS_DIFFERENT_DATABASE')
+        verify_database(destination, expected)
+        operation = 'unchanged'
+    else:
+        operation = 'install-database'
+    result = dict(ok=True, readOnly=not apply, applied=False, operation=operation,
+        destination=str(destination), databaseGeneration=expected['generation'], databaseSha256=expected['sha256'],
+        counts=counts, filesChecked=len(entries), databaseChecked=True, databaseWrites=0, databaseRowsChanged=0,
+        skillFilesModified=False, gatewayModified=False, environmentModified=False, businessRecordsChanged=0,
+        storageMode='static-snapshot', automaticUpdatesConfigured=False, productionBoardAligned=False,
+        networkRequests=0, realMessageVerified=False)
+    if not apply or operation == 'unchanged':
+        return result
+    target_dir.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
+    plain(target_dir.parent)
+    stage = Path(tempfile.mkdtemp(prefix='.ksf-voc-database-', dir=target_dir.parent))
+    os.chmod(stage, 0o700)
+    try:
+        target = stage / 'library.sqlite'
+        shutil.copyfile(source, target)
+        os.chmod(target, 0o600)
+        verify_database(target, expected)
+        if target_dir.exists() or target_dir.is_symlink():
+            raise ValueError('CONCURRENT_TARGET_CREATED')
+        stage.rename(target_dir)
+        result.update(applied=True, filesystemVerified=True)
+    finally:
+        if stage.exists():
+            shutil.rmtree(stage)
+    # The staged database was verified before the directory rename; independently
+    # reread the landed copy without ever opening SQLite in write mode.
+    verify_database(destination, expected)
+    return result
+
+
+if __name__ == '__main__':
+    parser = argparse.ArgumentParser(description=__doc__)
+    parser.add_argument('--package', required=True)
+    parser.add_argument('--target-dir', default='/opt/data/ksf-voc-library')
+    mode = parser.add_mutually_exclusive_group()
+    mode.add_argument('--check', action='store_true')
+    mode.add_argument('--apply', action='store_true')
+    args = parser.parse_args()
+    try:
+        print(json.dumps(install(args.package, args.target_dir, args.apply), ensure_ascii=False, indent=2))
+    except (ValueError, OSError, KeyError, sqlite3.Error) as error:
+        print(json.dumps(dict(ok=False, error=dict(code=str(error))), ensure_ascii=False), file=sys.stderr)
+        raise SystemExit(1)

+ 142 - 0
scripts/install-wecom-tool-result-hotfix.py

@@ -0,0 +1,142 @@
+"""Exact two-file patch. Default is read-only; never restarts the gateway."""
+import argparse
+import ast
+import hashlib
+import json
+import os
+from pathlib import Path
+import stat
+import tempfile
+
+ROOT = Path(__file__).resolve().parent.parent
+TARGET = Path('/opt/hermes/plugins/platforms/wecom')
+BEFORE = {
+    'workshop_registration.py': 'ecef06cacb4e05cc84ee3256fd6841f0da2bfa4d1cf69f39a2e6f38b05461d3e',
+    'identity_bridge.py': 'fa26f0c8918650a3d68869026ce547fa7ed0e50974b60819c954105ba7b16e01',
+}
+
+
+class PatchError(Exception):
+    pass
+
+
+def digest(data):
+    return hashlib.sha256(data).hexdigest()
+
+
+def plain(path):
+    if not path.is_absolute() or path.resolve() != path:
+        raise PatchError('UNSAFE_PATCH_PATH')
+    for p in (path, *path.parents):
+        if p.is_symlink():
+            raise PatchError('UNSAFE_PATCH_PATH')
+    info = path.lstat()
+    if not stat.S_ISREG(info.st_mode):
+        raise PatchError('NOT_PLAIN_FILE')
+    return info
+
+
+def atomic(path, content, mode, uid, gid):
+    fd, name = tempfile.mkstemp(prefix='.' + path.name + '.hotfix-', dir=path.parent)
+    try:
+        with os.fdopen(fd, 'wb') as f:
+            f.write(content)
+            f.flush()
+            os.fsync(f.fileno())
+            os.fchmod(f.fileno(), mode)
+            if hasattr(os, 'fchown'):
+                os.fchown(f.fileno(), uid, gid)
+        os.replace(name, path)
+    finally:
+        if os.path.exists(name):
+            os.unlink(name)
+
+
+def install(source_root=ROOT, target=TARGET, apply=False, backup_dir=None):
+    manifest = json.loads((source_root / 'HOTFIX.json').read_text(encoding='utf-8'))
+    if manifest.get('version') != 'ksf.wecom-tool-result-hotfix.v1' or set(manifest.get('files', {})) != set(BEFORE):
+        raise PatchError('INVALID_HOTFIX_MANIFEST')
+    entries = []
+    for name, old_hash in BEFORE.items():
+        origin = source_root / 'integrations/wecom-identity' / name
+        plain(origin)
+        data = origin.read_bytes()
+        expected = manifest['files'][name]
+        if expected['beforeSha256'] != old_hash or digest(data) != expected['sha256']:
+            raise PatchError('PACKAGE_HASH_MISMATCH')
+        ast.parse(data.decode('utf-8'))
+        dest = target / name
+        info = plain(dest)
+        prior = dest.read_bytes()
+        if digest(prior) not in (old_hash, digest(data)):
+            raise PatchError('UNKNOWN_TARGET_VERSION')
+        entries.append({'name': name, 'path': dest, 'data': data, 'prior': prior, 'mode': stat.S_IMODE(info.st_mode), 'uid': info.st_uid, 'gid': info.st_gid})
+    journal = {'ok': True, 'readOnly': not apply, 'operation': 'tool-result-hotfix-plan' if not apply else 'tool-result-hotfix-installed',
+               'gatewayRestarted': False, 'businessWrites': 0, 'identityStateChanged': False,
+               'files': [{'name': e['name'], 'beforeSha256': digest(e['prior']), 'afterSha256': digest(e['data']),
+                          'action': 'unchanged' if e['prior'] == e['data'] else 'update'} for e in entries]}
+    if not apply:
+        return journal
+    changed = [e for e in entries if e['prior'] != e['data']]
+    if not changed:
+        return journal
+    if backup_dir is None or not backup_dir.is_absolute() or backup_dir.exists() or backup_dir.parent.resolve() != backup_dir.parent or backup_dir.parent == target or backup_dir.parent == source_root:
+        raise PatchError('NEW_ABSOLUTE_BACKUP_REQUIRED')
+    if target == backup_dir or target in backup_dir.parents or backup_dir in target.parents:
+        raise PatchError('BACKUP_TARGET_OVERLAP')
+    for parent in backup_dir.parents:
+        if parent.is_symlink():
+            raise PatchError('UNSAFE_PATCH_PATH')
+    backup_dir.mkdir(mode=0o700)
+    journal['backup'] = str(backup_dir)
+    for e in changed:
+        with (backup_dir / e['name']).open('xb') as f:
+            f.write(e['prior'])
+        os.chmod(backup_dir / e['name'], 0o600)
+    with (backup_dir / 'before.json').open('x', encoding='utf-8') as f:
+        json.dump([{'name': e['name'], 'sha256': digest(e['prior']), 'uid': e['uid'], 'gid': e['gid'], 'mode': e['mode']} for e in changed], f)
+    os.chmod(backup_dir / 'before.json', 0o600)
+    done = []
+    try:
+        for e in changed:
+            info = plain(e['path'])
+            if e['path'].read_bytes() != e['prior'] or (info.st_uid, info.st_gid, stat.S_IMODE(info.st_mode)) != (e['uid'], e['gid'], e['mode']):
+                raise PatchError('TARGET_CHANGED_CONCURRENTLY')
+            done.append(e)
+            atomic(e['path'], e['data'], e['mode'], e['uid'], e['gid'])
+        for e in entries:
+            if e['path'].read_bytes() != e['data']:
+                raise PatchError('PATCH_READBACK_MISMATCH')
+        return journal
+    except Exception:
+        complete = True
+        for e in reversed(done):
+            try:
+                plain(e['path'])
+                current = e['path'].read_bytes()
+                if current == e['prior']:
+                    continue
+                if current != e['data']:
+                    complete = False
+                    continue
+                atomic(e['path'], e['prior'], e['mode'], e['uid'], e['gid'])
+            except Exception:
+                complete = False
+        error = PatchError('HOTFIX_INSTALL_FAILED')
+        error.receipt = {**journal, 'ok': False, 'rollbackComplete': complete}
+        raise error from None
+
+
+if __name__ == '__main__':
+    try:
+        parser = argparse.ArgumentParser()
+        parser.add_argument('--apply', action='store_true')
+        parser.add_argument('--backup-dir', type=Path)
+        args = parser.parse_args()
+        if os.name != 'posix' or os.geteuid() != 0:
+            raise PatchError('LINUX_ROOT_REQUIRED')
+        print(json.dumps(install(apply=args.apply, backup_dir=args.backup_dir), ensure_ascii=False, indent=2))
+    except Exception as exc:
+        code = str(exc) if isinstance(exc, PatchError) else 'HOTFIX_STOPPED'
+        print(json.dumps(getattr(exc, 'receipt', {'ok': False, 'error': {'code': code}, 'gatewayRestarted': False}), ensure_ascii=False))
+        raise SystemExit(1) from None

+ 83 - 0
scripts/install-workshop-skill.mjs

@@ -0,0 +1,83 @@
+import fs from 'node:fs';
+import path from 'node:path';
+import crypto from 'node:crypto';
+import {fileURLToPath} from 'node:url';
+const hash=b=>crypto.createHash('sha256').update(b).digest('hex');
+const expectedBefore={
+ 'SKILL.md':'41d8fe2cb437525e6418e18e0fc5c95777f27235ead3fd9b0594f27b8efe16ae',
+ 'scripts/ksf-secure.mjs':'33074dfaf410debd49382c7b15658ffe720099e62722f1ed981392ccf4c635d1',
+ 'scripts/ksf.mjs':'7ef128f7d022bfbc15c0ec813bfd87995c6297c6efe407a563c840f014aba477',
+ 'runtime-lock.json':'2605cbe8fd7be43b5969a4991f978dc0768dee5b4d65d18f7af15f75e9004a79',
+ 'references/write-tasks.md':'d1564bca70d8dff6eeb3f3465fe88c0abd59c5ef30ecc61603c57985ae4fa2ef'
+};
+const expectedWorkshop={
+ ...expectedBefore,
+ 'SKILL.md':'09ea66572276896adb3ce8d14146914b67dba353a5b125cf0a5e51bde404cd49',
+ 'scripts/ksf-secure.mjs':'d76bf8a28235441b7c7c98fec3319e7a5038c12c34b28dc98da92ad674075fa0',
+ 'scripts/ksf-workshop.mjs':'14deb2145ea4714e5842110a37d568e24bcf05b760b46e46d2ccf63e103cc19b',
+ 'references/workshop-tasks.md':'0ebd5252dc95e3d249c5d88a59bc55892ea3ed317eb92de410c7aefbdfa7bd8e'
+};
+const expectedTwoAgents={
+ ...expectedWorkshop,
+ 'scripts/ksf-identity.mjs':'5e592ab775c3b47a08de87f773a4df867f23d5040862f92b8e964142be8b40ae',
+ 'scripts/ksf-workshop.mjs':'63851a10688d5363f5cd1bd07196bff03dfb8427b8c9fec83dd9aacf6440dcb0',
+ 'SKILL.md':'92e305a5f64dd43d0e15b9ad093228a53b5412a774ffbfb942837f7810f69b73',
+ 'scripts/ksf-secure.mjs':'bbbf35568f0f2c315b7ca5e78bc7f41ff94611bffc10f0f240be5a7e22326835',
+ 'references/identity.md':'d17086c124577214c7e8156ae4214fde7aada6020d3aafe0f9387b9d2bdd4727',
+ 'references/workshop-tasks.md':'d17e5084ce03a634cffd560180ceb0a640a3437d95a1b08e3c5b17f4a728805a'
+};
+const fail=code=>{throw Object.assign(Error(code),{code});};
+const expectedRegistry={...expectedWorkshop,
+ 'SKILL.md':'472c17778b72cf17d6346cf479a3d58b37a4bb6fbf9c79850f3a93b901039710',
+ 'references/identity.md':'2338220275d1d7dd1f0a6c4198f1b4c66a1026f167c281bd8f495a723a383403',
+ 'references/workshop-tasks.md':'93108b19247ea0ef3fdd5871dc8bcc5aaff9d43e8b71ade49ef48eceba690bb9',
+ 'scripts/ksf-secure.mjs':'aea216b67b5d077a05aa5ccbf78cc55f4b0975437a39b829f107d56f1eb5a3cf',
+ 'scripts/ksf-workshop.mjs':'be38359c8d032cf719424c3cc6723b47011fab28435b21315e3363e3bc8b76bd',
+ 'scripts/ksf-identity.mjs':'c73ec1cd5ad3302a24f15aab0ea956f163609a7f1d88d20b04b51e47f9c6ed05'
+};
+const expectedAibot={...expectedRegistry,
+ 'SKILL.md':'e71ce988fdcbd8240e37b62c928a5ca0b63c74e5662ca9fc84d34a608e709e3f',
+ 'references/identity.md':'19c3c321e4f0a318e062f520bc7b2568a4d2b6dac66c2685a6260999bec6b99f',
+ 'references/workshop-tasks.md':'aa165aa0ff0e816fbadd72db1ac04a3ca6794ed0d4b97215ef33e1cefa179611',
+ 'scripts/ksf-identity.mjs':'23165b200c1526fcbf4a3c50c95a194466ec337441100a23d956f6b3c4e1eb2b',
+ 'scripts/ksf-workshop.mjs':'cc1861ac2c5fa82014447fbb303588ee8e4b595a2d854707a0f85d30d6287f72',
+ 'scripts/ksf-secure.mjs':'baa6d82e7a172753efc2a28923628a17184a0aba4ea2b6ebfbc2be9545990a34'
+};
+const expectedCurrentIntegration={"runtime-lock.json":"2605cbe8fd7be43b5969a4991f978dc0768dee5b4d65d18f7af15f75e9004a79","scripts/ksf-identity-bridge.mjs":"6ba3f0cb6527bc4a0a6218a898472ac65aa3dd9f3f57b597d77efcee641a7540","scripts/ksf.mjs":"7ef128f7d022bfbc15c0ec813bfd87995c6297c6efe407a563c840f014aba477","references/identity.md":"8495c6d664fc5d445ab10af90484e39aead98ca2c8bc73e592db46ba277d4449","scripts/ksf-identity.mjs":"9224ef89a96425890d54961fda1f4fcae3403aa7c753fe1d70ac62082f2ff77e","references/workshop-tasks.md":"aa165aa0ff0e816fbadd72db1ac04a3ca6794ed0d4b97215ef33e1cefa179611","scripts/ksf-secure.mjs":"aa25ad0a11e357744fd2aa0d679cfeb3436ef3ca657bdbf6f16173daa1ae65f4","scripts/ksf-workshop.mjs":"cc1861ac2c5fa82014447fbb303588ee8e4b595a2d854707a0f85d30d6287f72","references/write-tasks.md":"d1564bca70d8dff6eeb3f3465fe88c0abd59c5ef30ecc61603c57985ae4fa2ef","SKILL.md":"d334df2c0a4b351ca0635273b669b6b88f4ac5075791fab61419cd29f56e236b"};
+function plain(p){const s=fs.lstatSync(p);if(!s.isFile()||s.isSymbolicLink())fail('NOT_PLAIN_FILE');}
+function stateSnapshot(root){if(!fs.existsSync(root))return{};const result={};function walk(dir){if(fs.lstatSync(dir).isSymbolicLink())fail('SYMLINK_STATE_DIRECTORY');for(const e of fs.readdirSync(dir,{withFileTypes:true})){const file=path.join(dir,e.name);if(e.isSymbolicLink())fail('SYMLINK_STATE_DIRECTORY');if(e.isDirectory())walk(file);else{plain(file);result[path.relative(root,file)]=hash(fs.readFileSync(file));}}}walk(root);return result;}
+export function installWorkshopSkill(source,destination){
+ source=path.resolve(source);destination=path.resolve(destination);
+ if(fs.realpathSync(destination)!==destination)fail('SYMLINK_DESTINATION');
+ for(const dir of ['scripts','references']){const stat=fs.lstatSync(path.join(destination,dir));if(!stat.isDirectory()||stat.isSymbolicLink())fail('SYMLINK_SUBDIRECTORY');}
+ const binding=path.join(destination,'credential-binding.json');plain(binding);const bindingHash=hash(fs.readFileSync(binding));
+ const state=path.resolve(destination,'../../ksf-workshop-state');
+ const beforeState=stateSnapshot(state);
+ const entries=fs.readFileSync(path.join(source,'FILES.sha256'),'utf8').trim().split(/\r?\n/).map(line=>{
+  const m=line.match(/^([a-f0-9]{64})  ([A-Za-z0-9_./-]+)$/);if(!m||m[2].split('/').includes('..')||path.isAbsolute(m[2]))fail('BAD_MANIFEST');
+  const origin=path.resolve(source,m[2]);if(!origin.startsWith(source+path.sep))fail('BAD_MANIFEST');plain(origin);if(hash(fs.readFileSync(origin))!==m[1])fail('PACKAGE_HASH_MISMATCH');return{name:m[2],hash:m[1],origin,target:path.join(destination,m[2])};
+ });
+ const expectedNames=[...Object.keys(expectedWorkshop),'scripts/ksf-identity.mjs','scripts/ksf-identity-bridge.mjs','references/identity.md'].sort();
+ if(JSON.stringify(entries.map(x=>x.name).sort())!==JSON.stringify(expectedNames))fail('UNEXPECTED_SKILL_FILES');
+ const changes=[];
+ for(const e of entries){
+  if(fs.existsSync(e.target)){plain(e.target);const before=fs.readFileSync(e.target),current=hash(before);if(current===e.hash)continue;if(![expectedBefore[e.name],expectedWorkshop[e.name],expectedTwoAgents[e.name],expectedRegistry[e.name],expectedAibot[e.name],expectedCurrentIntegration[e.name]].includes(current))fail('TARGET_HAS_UNEXPECTED_CONTENT');changes.push({...e,before});}
+  else{if(expectedBefore[e.name])fail('BASELINE_FILE_MISSING');changes.push({...e,before:null});}
+ }
+ const backup=changes.length?fs.mkdtempSync(path.join(path.dirname(path.dirname(destination)),'ksf-workshop-upgrade-backup-')):null;
+ const done=[];
+ try{
+  for(const e of changes.filter(x=>x.name!=='SKILL.md').concat(changes.filter(x=>x.name==='SKILL.md'))){
+   if(e.before){const file=path.join(backup,e.name);fs.mkdirSync(path.dirname(file),{recursive:true});fs.writeFileSync(file,e.before,{flag:'wx',mode:0o600});}
+   const temp=e.target+'.update-'+crypto.randomUUID();fs.copyFileSync(e.origin,temp,fs.constants.COPYFILE_EXCL);fs.renameSync(temp,e.target);done.push(e);
+  }
+  for(const e of entries)if(hash(fs.readFileSync(e.target))!==e.hash)fail('INSTALLED_HASH_MISMATCH');
+  if(hash(fs.readFileSync(binding))!==bindingHash)fail('CREDENTIAL_BINDING_CHANGED');
+  if(JSON.stringify(stateSnapshot(state))!==JSON.stringify(beforeState))fail('IDENTITY_PROFILES_CHANGED');
+  return{ok:true,changed:changes.map(x=>x.name),destination,backup,credentialBindingPreserved:true,agentProfilePreserved:true,participantProfilesPreserved:true,stateDirectoryPreserved:true,legacyEntryPreserved:true,r7Modified:false,gatewayModified:false,discoveryVerified:false,note:'请重新加载技能并登记自身与员工档案;服务端身份扩展就绪后再从新消息验证业务。安装器不代表消息已触发。'};
+ }catch(e){for(const item of done.reverse()){if(item.before)fs.writeFileSync(item.target,item.before);else fs.unlinkSync(item.target);}throw e;}
+}
+if(process.argv[1]&&path.resolve(process.argv[1])===fileURLToPath(import.meta.url)){
+ try{if(process.platform!=='linux'||process.argv.length!==2)fail('LINUX_NO_ARGUMENTS_REQUIRED');const root=path.resolve(path.dirname(fileURLToPath(import.meta.url)),'..');console.log(JSON.stringify(installWorkshopSkill(path.join(root,'skills/ksf-workshop-runtime'),'/opt/data/skills/ksf-workshop-runtime'),null,2));}
+ catch(e){console.error(JSON.stringify({ok:false,error:{code:/^[A-Z][A-Z0-9_]+$/.test(e.code||'')?e.code:'INSTALL_FAILED',message:'安装停止;保留现场,未输出凭据或原始异常。'}}));process.exitCode=1;}
+}

+ 202 - 0
scripts/package-S4-public-research.py

@@ -0,0 +1,202 @@
+"""Build a reviewable public-source packet. No business API calls or writes."""
+from datetime import datetime, timezone
+import hashlib
+import json
+from pathlib import Path
+import shutil
+import zipfile
+
+ROOT = Path(__file__).resolve().parents[1]
+CAPTURE = ROOT / 'work/S4-web-research-20261103'
+DOCS = Path('F:/文档/工作坊当前资料')
+STAGE = ROOT / 'work/S4-public-materials-20261103'
+ZIP = DOCS / 'ksf-S4-public-materials-20261103.zip'
+assert not STAGE.exists() and not ZIP.exists()
+STAGE.mkdir()
+
+
+def sha(data):
+    return hashlib.sha256(data).hexdigest()
+
+
+def put(name, data):
+    target = STAGE / name
+    target.parent.mkdir(parents=True, exist_ok=True)
+    with target.open('xb') as stream:
+        stream.write(data.encode('utf-8') if isinstance(data, str) else data)
+
+
+result = '''# 网上测试资料核查与推进说明
+
+核查日期:2026年11月3日,Codex实际访问公开网页、匿名浏览器查看及原图目视核对。当前测试不用先由公司提供照片。40号状态C仍保留:它只说明此前数字人机器的检查范围没有实物原件;本轮新取得网上资料不否定旧回报。41号外部补料改为可选,实物研究需要时再用。
+
+已经取得两组直接资料,不使用搜索摘要作证据:
+
+| 材料 | 实际查看到的内容 | 定性与限制 |
+|---|---|---|
+| 苏宁公开高清图片页,商品编号12447641634 | 标题“康师傅方便面 燃魂拌面 浓浓炸酱面117g*5袋 非油炸 煮面夜宵速食五连包”;参数净含量585克、袋装;5张关联商品图 | 本次可访问的国内平台页面展示;不代表已下单、库存、实际发货规格或当前包装版本已核 |
+| 同页第5张单袋正面图 | “浓浓炸酱面”“煮面6-7分钟”“净含量:面饼+配料117克 面饼:90克” | 原图可读的包装标示;117克是含配料净含量,90克是图示面饼重量,均不是本轮称量值 |
+| 什么值得买帖子aeqxm74k,显示2023-12-18 21:20:15,作者“没事儿聊聊呗” | 开袋内容、煮制过程、包装背面照片;正文说“包装上写的煮面6-7分钟,实测写少了,煮了10分钟面才软。” | 历史作者自述和其展示的包装照片;不把作者的10分钟当成我们的试验结果或现行统一说明 |
+| 该帖第10张图(本包编号post-10) | 背面名“浓浓炸酱面”;制备段可读“先准备1L以上水煮沸,加入面条”“2分钟后挑散面条,4分钟放入菜包,煮面6-7分钟捞出”;有配料和营养表 | 是网上历史包装图,拍摄时间、批次及完整条码未核;不是公司持有实物。后面三张比较图属于其他商品,本交付包不纳入 |
+
+苏宁标题117g×5与参数585克算术一致;这不证明页面图与当前仓内实物相同。帖中背面与苏宁正面都显示浓浓炸酱面及6—7分钟,但没有将两来源合成同一SKU或同批次。旧cq-01至04与本次商品的精确SKU匹配继续未知。
+
+## 可直接使用的测试对象
+
+P1:苏宁12447641634页面所示的“燃魂拌面·浓浓炸酱面·袋装117g×5”;面饼90克与煮6—7分钟均明确写为商品图标示。P1是公开资料的对象标记,没有创建KfProduct或其他产品实体。
+
+P2:2023年帖子展示的浓浓炸酱面包装及作者的制备叙述。单独保存为历史旁证,不能与P1拼成一份完整当前SKU档案。帖中配料评价“干净”“健康”不采信为营养或安全结论;作者展示的订单截图也没有被当作已验证购买。
+
+## 可以推进什么
+
+当前材料足以继续公开资料支持的流程测试。已整理43号《S4公开资料桌面评估草稿》,将事实、假设、证据缺口与下一步检验分开,不必再让王刚001重新写同一份准备稿。
+
+1. 规格信息核对:在一个真实页面内分别提取含料净含量、面饼重量与组合件数,检查标题/参数/图片是否吻合。
+2. 煮制告知桌面核对:确认页面标题含“煮面”,单袋正面含时间,历史背面展示步骤;不能据此声称消费者已看懂、页面已有效提升认知。
+3. 份量假设的证据与可行性评估:以明确的网页对象记录已有标示和缺口;是否吃饱、自然搭配、支付意愿等仍未测试。
+
+实际试吃、称量、招募、页面认知测试和量产评估需要对应实物、人员或企业资料,届时分别补充;它们不是当前全部软件流程测试统一必备的前置。
+
+## 来源与归档
+
+- 苏宁原页:https://m.suning.com/bigimages/12447641634.html
+- 单袋正面原图:https://imgservice.suning.cn/uimg1/b2c/image/4_-1P8wJu87-d-WFGC3YAg.jpg
+- 历史帖子:https://post.smzdm.com/p/aeqxm74k/
+- 历史背面原图:https://am.zdmimg.com/202312/18/65804034eddf17405.jpg_e1080.jpg
+
+原始HTTP响应、浏览器DOM与可见正文、匿名截图、选定7张图片原字节、来源索引、字段表及校验清单见同目录ksf-S4-public-materials-20261103.zip。HTTP正文的编码派生件不作唯一依据;报告引用采用浏览器可见正文,原始字节保留供复核。包装标示由原图目视核,不声称OCR自动逐字认证。
+
+实际采集时间保存在SOURCE-INDEX.json,UTC与北京时间可区分;2023年发表时间保持页面原串,不推断时区。浏览器临时档案不入包。原图未修改,联系表是另存的阅读辅助件。
+
+## 当前系统状态
+
+系统仍为S3,会话btrFqkz0yw。S3第2版ZW1FB5zeHr和S1/S2不动。没有调用任何workshop接口,没有入库、创建产品、独立机会卡/项目或保存S4。43号是可审阅正文,不是可执行保存JSON。
+
+若下一步要测试系统内S4,应先明确选择一个待验证方向,并按现有合同关联独立机会卡及课堂项目,再保存项目下的评估;课堂项目不等于企业审批。当前“份量与用餐目的适配”可作为沿用S3的候选方向,不能擅自称员工已经选定。无需为了进入S4虚构试吃、评分、成本或市场结论。
+'''
+
+assessment = '''# S4公开资料桌面评估草稿
+
+性质:依据真实公开资料形成的证据与验证可行性评估,尚未保存系统S4。不是试吃结果、市场机会确认或量产结论。参考既有S3第2版ZW1FB5zeHr;四篇旧引文不修改、不重导。
+
+## 评估对象与问题
+
+沿用S3“燃魂份量与用餐目的适配”作为候选问题:对明确的一份,在实际用餐目的下是否需要额外补主食?本次P1仅锁定苏宁12447641634页面所示浓浓炸酱面117g×5;当前实物、版本及与2023年原话对应的精确SKU未知。
+
+## 已做的核对及结果
+
+| 评估项 | 直接依据 | 本次结果 | 仍不能判定 |
+|---|---|---|---|
+| 页面内规格一致性 | 苏宁标题117g×5,参数585克,单袋图面饼+配料117克、面饼90克 | 标题总重与参数总重算术一致,图示单份含料净含量吻合;两种重量分开记录 | 现货、实发版本、实物称量、配料包实测重量 |
+| 制备说明是否存在 | 苏宁单袋正面煮6—7分钟;2023年帖子背面准备1L以上水、分时挑散/放菜包/捞出 | 这两个来源中能找到煮制信息,来源与时点分开保留 | 页面首屏是否被用户注意、是否看懂;不证明绝不能泡;不代表全系列当前说明 |
+| 份量假设的支持程度 | 旧cq-03/_9mUml0wmh、cq-04/2bFGP7axMo两篇历史自述;本次网页图示重量 | 有历史问题线索,也有可供桌面分析的具体网页对象 | 旧帖是否同SKU、当前用户是否吃不饱、需求强度、普遍程度及支付意愿 |
+| 烹饪表现 | 新帖作者称自己煮10分钟才软;背面图示6—7分钟 | 只保留为历史个体叙述,不用10分钟替代包装说明 | 水量、火力、软硬偏好等未控制;不能据此判包装错误或实际工艺表现 |
+| 量产、成本与合规 | 未取得公司配方、产线/原料依据、报价或相应检验资料 | 无法评估,保留未知 | 不填写可量产、成本几乎为零或合规已通过 |
+
+## 三个假设如何处理
+
+份量方向:保留为待验证问题,本次没有证明加量必要。加菜与补主食的动机分开记录,提前补食和吃后补食都可能相关,习惯与补偿可并存。不以重量本身换算能否吃饱。
+
+煮制告知方向:本页标题已有“煮面”,单袋图可见6—7分钟,所以下一步问题收窄为真实购买路径上的可见性与理解。包装有字不等于消费者已理解;本次没有受试者或A/B数据,也没有证明现页误导。
+
+香辣留水方向:本次没有取得该方向具体商品的新原件,保留原假设。不得套用燃魂浓浓炸酱面的重量、时间或说明,不泛称所有香辣款均可泡。
+
+## 判断与下一步
+
+本次完成的是公开资料核对,可继续进行软件中的来源关联、证据审阅和阶段评估测试;实际研究结论尚不成立。无需反复寻找本机不存在的公司样品,亦无需等待用户先拍照。
+
+若继续系统S4测试,先按既有合同将员工选定的候选方向关联课堂机会卡/项目,再关联新来源与评估报告;不能把S3的artifactType=opportunity_card当成已存在独立机会卡。不在无项目时冒报项目阶段已推进。
+
+若以后实施份量研究,再补实际样品、获取记录和适合目标的观察方案;若先研究购买告知,补真实购买路径与观察对象。样本数、判断门槛、成本、负责人、排期未定不虚填。取得相反证据或动机混杂时调整问题;证据不足不等于已经证明无效。
+
+本草稿不生成数值评分、KANO分类、配方、采购或招募;没有业务写入。来源详情及原件包见42号。
+'''
+
+put('42-网上测试资料核查与推进说明.md', result)
+put('43-S4公开资料桌面评估草稿.md', assessment)
+
+checks = []
+for name in ('suning-photo-117', 'post-label-review'):
+    http = json.loads((CAPTURE / (name + '.capture.json')).read_text(encoding='utf-8'))
+    assert http['status'] == 200
+    assert sha((CAPTURE / (name + '.response.bin')).read_bytes()) == http['responseSha256']
+    for ext in ('.capture.json', '.response.bin', '.text.txt'):
+        put('http/' + name + ext, (CAPTURE / (name + ext)).read_bytes())
+    for ext in ('.dom.html', '.text.txt', '.png', '.detail-0.png'):
+        put('browser/' + name + ext, (CAPTURE / 'browser' / (name + ext)).read_bytes())
+    checks.append({'check': name + ': HTTP response bytes match captured SHA256', 'passed': True})
+put('browser/observations.json', (CAPTURE / 'browser/observations.json').read_bytes())
+for name in ('post-method-context', 'suning-label-image'):
+    put('browser/' + name + '.png', (CAPTURE / 'browser-details' / (name + '.png')).read_bytes())
+
+all_images = json.loads((CAPTURE / 'images/index.json').read_text(encoding='utf-8'))
+selected = []
+for item in all_images:
+    if not (item['id'].startswith('suning-') or item['id'] in ('post-04', 'post-10')):
+        continue
+    data = (CAPTURE / item['file']).read_bytes()
+    assert sha(data) == item['sha256'] and item['status'] == 200
+    put(item['file'], data)
+    selected.append(item)
+assert len(selected) == 7
+put('images/index.json', json.dumps(selected, ensure_ascii=False, indent=2) + '\n')
+put('images/suning-contact-sheet.png', (CAPTURE / 'images/suning-contact-sheet.png').read_bytes())
+
+observations = json.loads((CAPTURE / 'browser/observations.json').read_text(encoding='utf-8'))
+retail = next(x for x in observations if x['id'] == 'suning-photo-117')
+post = next(x for x in observations if x['id'] == 'post-label-review')
+for phrase in ('浓浓炸酱面117g*5袋', '585克', '袋装'):
+    assert phrase in retail['text']
+    checks.append({'check': 'Browser retail body contains: ' + phrase, 'passed': True})
+for phrase in ('2023-12-18 21:20:15', '没事儿聊聊呗', '包装上写的煮面6-7分钟,实测写少了,煮了10分钟面才软。'):
+    assert phrase in post['text']
+    checks.append({'check': 'Browser post body contains: ' + phrase, 'passed': True})
+checks.append({'check': 'Seven selected original image files match HTTP capture hashes', 'passed': True})
+
+index = {
+    'createdAtUtc': datetime.now(timezone.utc).isoformat(), 'businessApiCalls': 0,
+    'businessWrites': 0, 'authenticated': False, 'physicalSampleVerified': False,
+    'purpose': 'Public-source workflow testing and desk assessment; not physical validation',
+    'sources': [{k: obs[k] for k in ('id', 'requestedUrl', 'url', 'title', 'startedAt', 'observedAt', 'documents')}
+                for obs in observations],
+    'images': selected,
+    'manualImageReadings': [
+        {'file': 'images/suning-05.jpg', 'region': 'lower-left label',
+         'visibleText': '净含量:面饼+配料117克 面饼:90克',
+         'scope': 'Visible product image label, not measured values'},
+        {'file': 'images/suning-05.jpg', 'region': 'middle front', 'visibleText': '煮面6-7分钟'},
+        {'file': 'images/post-10.jpg', 'region': 'upper back preparation strip',
+         'visibleText': ['先准备1L以上水煮沸,加入面条', '2分钟后挑散面条,4分钟放入菜包,煮面6-7分钟捞出'],
+         'scope': 'Historical image; complete barcode, batch and photograph date unknown'}
+    ],
+    'checks': checks,
+    'excluded': ['Search snippets are discovery only', 'Unrelated comparison products in post photos 11-13',
+                 'Unverified shopping, identity and health claims', 'Browser profiles and credentials'],
+}
+put('SOURCE-INDEX.json', json.dumps(index, ensure_ascii=False, indent=2) + '\n')
+put('README.txt', '真实公开资料测试包,2026-11-03\n先读42号结果与43号桌面评估。未包含执行命令或保存输入,不直接import/save。\n七张选定图片保留下载原字节,索引含URL/时间/SHA256;网页和历史包装不合并SKU。\nFILES.sha256为除自身外所有文件的校验清单;没有浏览器档案、凭据或无关商品照片。\n实际系统仍S3,未调用业务接口。\n')
+
+manifest = []
+for file in sorted(STAGE.rglob('*')):
+    if file.is_file():
+        manifest.append(f'{sha(file.read_bytes())}  {file.relative_to(STAGE).as_posix()}')
+put('FILES.sha256', '\n'.join(manifest) + '\n')
+with zipfile.ZipFile(ZIP, 'x', compression=zipfile.ZIP_DEFLATED, compresslevel=7) as archive:
+    for file in sorted(STAGE.rglob('*')):
+        if file.is_file():
+            archive.write(file, file.relative_to(STAGE).as_posix())
+with zipfile.ZipFile(ZIP) as archive:
+    assert archive.testzip() is None
+    for row in manifest:
+        digest, name = row.split('  ', 1)
+        assert sha(archive.read(name)) == digest
+
+receipt = {'zip': str(ZIP), 'bytes': ZIP.stat().st_size, 'sha256': sha(ZIP.read_bytes()),
+           'fileCount': len(manifest) + 1, 'manifestEntriesVerified': len(manifest),
+           'checksPassed': len(checks), 'businessApiCalls': 0, 'businessWrites': 0}
+with (ROOT / 'work/S4-public-materials-20261103-receipt.json').open('x', encoding='utf-8') as stream:
+    json.dump(receipt, stream, ensure_ascii=False, indent=2)
+for name in ('42-网上测试资料核查与推进说明.md', '43-S4公开资料桌面评估草稿.md'):
+    with (DOCS / name).open('xb') as stream:
+        stream.write((STAGE / name).read_bytes())
+print(json.dumps(receipt, ensure_ascii=True, indent=2))

+ 109 - 0
scripts/package-S5-classroom-next.py

@@ -0,0 +1,109 @@
+"""Package prepared, reviewed and checked S5 inputs. Local filesystem only."""
+from pathlib import Path
+import hashlib
+import json
+import zipfile
+
+ROOT = Path(__file__).resolve().parents[1]
+DOCS = Path('F:/文档/工作坊当前资料')
+OUT = ROOT / 'work/S5-classroom-next-20261103'
+ZIP = DOCS / 'ksf-S5-classroom-next-20261103.zip'
+assert not ZIP.exists()
+audit = json.loads((ROOT / 'work/S5-classroom-live-preflight-20261103.json').read_text(encoding='utf-8'))
+simulation = json.loads((ROOT / 'work/S5-classroom-isolated-simulation-20261103.json').read_text(encoding='utf-8'))
+assert audit['ok'] and simulation['ok']
+assert audit['currentStage'] == 'S4' and audit['productionBusinessWrites'] == simulation['productionWrites'] == 0
+assert len(simulation['calls']) == 8
+assert simulation['expectedFinal']['progressState'] == 'in_progress'
+
+preflight_file = OUT / 'preflight.json'
+preflight = json.loads(preflight_file.read_text(encoding='utf-8'))
+preflight.update({
+    'liveReadOnlyPreflight': {'at': audit['at'], 'checksPassed': audit['checksPassed'], 'currentStage': audit['currentStage'],
+        'projectId': audit['projectId'], 'conceptCount': audit['conceptCount'], 's5ArtifactCount': audit['s5ArtifactCount'],
+        'matchedFutureWriteAuditRows': audit['matchedFutureWriteAuditRows'], 'deployedCommandCount': len(audit['deployedCodeChecks']),
+        'productionBusinessWrites': 0, 'remoteFilesystemAndProcessesInspected': False, 'currentMessageIdentityVerifiedByCodex': False},
+    'localSimulation': {'at': simulation['at'], 'checksPassed': simulation['checksPassed'], 'orderedCommandCount': len(simulation['calls']),
+        'negativeControls': simulation['negativeControls'], 'productionCalls': 0, 'productionWrites': 0,
+        'expectedFinal': simulation['expectedFinal'], 'simulatedOnly': True},
+})
+preflight_file.write_text(json.dumps(preflight, ensure_ascii=False, indent=2) + '\n', encoding='utf-8', newline='\n')
+
+review_file = OUT / '52-S5桌面方案审阅与定稿.md'
+with review_file.open('a', encoding='utf-8', newline='\n') as f:
+    f.write(f'''
+独立准备结果:现网只读预检{audit['checksPassed']}项通过,当前仍S4/in_progress,无concept、S5系列成果或本次成功写入审计。四来源及证据绑定/quote重新按编号读回;S4固定内容、归属保持一致。当前4个部署入口的相关命令体及会话/项目/事件/幂等处理与核对实现匹配,包含concept-save的精确字段与pendingQuestions持久化位置。
+
+以本次捕获的现网处理代码和运行时字段表,在本地内存完成8步模拟,{simulation['checksPassed']}项结果检查及2个反向控制通过:无项目S4成果不能转S5;首次concept不能跳过v1。模拟最终S5/in_progress、同项目3份成果、1个concept v1、0配料条目、7事件,预期3条成功写入。模拟没有生产调用或写入,不替代未来每步回读与匿名生产浏览器验收。
+
+本地独立记录:F:/kangshifu-board/work/S5-classroom-live-preflight-20261103.json、F:/kangshifu-board/work/S5-classroom-isolated-simulation-20261103.json。preflight.json中localStructureOnly描述原始模板生成,后续liveReadOnlyPreflight与localSimulation另行列出;不能把本轮概括为“从未访问业务接口”,实际发生了生产只读访问。
+''')
+
+readme_file = OUT / 'README.txt'
+with readme_file.open('a', encoding='utf-8', newline='\n') as f:
+    f.write(f'已完成:现网只读预检{audit["checksPassed"]}项、8步内存模拟{simulation["checksPassed"]}项及2反向控制。远端落地、当前真实身份、在途与逐次生产回读仍须53/54执行。\n')
+
+names = sorted(p.name for p in OUT.iterdir() if p.is_file())
+assert len(names) == 10
+allowed = {'.json', '.md', '.txt'}
+for name in names:
+    assert Path(name).suffix in allowed
+    data = (OUT / name).read_text(encoding='utf-8')
+    assert '-----BEGIN' not in data and 'X-Parse-Master-Key' not in data and 'signingSecret' not in data
+
+manifest = [hashlib.sha256((OUT / name).read_bytes()).hexdigest() + '  ' + name for name in names]
+with (OUT / 'FILES.sha256').open('x', encoding='utf-8', newline='\n') as f:
+    f.write('\n'.join(manifest) + '\n')
+with zipfile.ZipFile(ZIP, 'x', compression=zipfile.ZIP_DEFLATED, compresslevel=7) as bundle:
+    for p in sorted(OUT.iterdir()):
+        bundle.write(p, p.name)
+with zipfile.ZipFile(ZIP) as bundle:
+    assert bundle.testzip() is None
+    assert len(bundle.namelist()) == 11
+    for line in manifest:
+        digest, name = line.split('  ', 1)
+        assert hashlib.sha256(bundle.read(name)).hexdigest() == digest
+
+for name in ['52-S5桌面方案审阅与定稿.md', '53-发给运维001-S5方案与报告准备.txt',
+             '54-发给王刚001-S5方案与课堂报告保存.txt', 'S5桌面方案第1版-审阅稿.md']:
+    with (DOCS / name).open('xb') as f:
+        f.write((OUT / name).read_bytes())
+receipt = {
+    'ok': True, 'zip': str(ZIP), 'bytes': ZIP.stat().st_size, 'sha256': hashlib.sha256(ZIP.read_bytes()).hexdigest(),
+    'entries': 11, 'manifestVerified': 10, 'currentStage': 'S4', 'saved': False, 'productionBusinessWrites': 0,
+    'liveReadOnlyPreflightPassed': audit['checksPassed'], 'localSimulationChecksPassed': simulation['checksPassed'],
+    'templates': {name: {'bytes': (OUT / name).stat().st_size, 'sha256': hashlib.sha256((OUT / name).read_bytes()).hexdigest()}
+                  for name in names if name.endswith('.template.json')},
+}
+with (ROOT / 'work/S5-classroom-next-package-receipt-20261103.json').open('x', encoding='utf-8', newline='\n') as f:
+    json.dump(receipt, f, ensure_ascii=False, indent=2)
+    f.write('\n')
+
+backup = ROOT / 'work/S5-progress-before-package-20261103'
+backup.mkdir()
+for name in ['01-工作进展汇报.txt', '02-后续待补资料.md', 'README.md']:
+    with (backup / name).open('xb') as f:
+        f.write((DOCS / name).read_bytes())
+
+index_file = DOCS / 'README.md'
+index = index_file.read_text(encoding='utf-8')
+start = index.index('当前状态(')
+end = index.index('\n\n', start)
+index = index[:start] + '当前状态(2026年11月3日):S4 / in_progress。S4已独立验收;王刚001的S5文字草稿已按52号审阅定稿,三份固定模板与53/54号已准备,现网只读预检及8步本地模拟通过。下一步将53号与S5固定材料包交运维落地预检,再由王刚001按54号真实消息执行。尚未转S5、尚未保存concept或S5报告;不重复改稿、不要求先交实物,不重跑S1—S4。' + index[end:]
+old51 = next(line for line in index.splitlines() if line.startswith('- 51-'))
+index = index.replace(old51, '- 51-发给王刚001-S5桌面方案草稿.txt:已收到文字草稿,52号已审阅定稿,保留原单,不重跑该轮只读。\n- 52-S5桌面方案审阅与定稿.md:当前审阅结果;证据边界、研究记录与产品路径、13项未决问题已校正,无需再退回重写。\n- 53-发给运维001-S5方案与报告准备.txt:当前下一步;原样落地3模板并只读核合同、归属、来源、历史、在途。\n- 54-发给王刚001-S5方案与课堂报告保存.txt:运维预检通过后在真实消息执行8步;project-progress→concept-save→workshop-save,最终S5/in_progress,不自动completed。\n- S5桌面方案第1版-审阅稿.md:拟保存的正文审阅稿,尚未保存生产。\n- ksf-S5-classroom-next-20261103.zip:3固定模板、runbook、审阅及交接;11条目、10/10清单核验。sessionId/projectId为null,不可直接提交。')
+index_file.write_text(index, encoding='utf-8', newline='\n')
+
+progress_file = DOCS / '01-工作进展汇报.txt'
+text = progress_file.read_text(encoding='utf-8')
+old = next(line for line in text.splitlines() if line.startswith('下一步执行单51号'))
+text = text.replace(old, 'S5文字草稿已收到并按52号直接校正定稿:不以P1断言信息分散,不把P2当包装错误证明,不用未定义“多数”为停止线,研究记录与产品路径分开。原S4八条未决问题逐字保留,加五条设计问题共13条。三份固定模板、53号运维单、54号王刚执行单及审阅稿已打包;现网只读预检13项及8步本地模拟8项/2反向控制通过。系统仍S4,无concept或S5报告、无本次成功写入。下一步交53号+S5包运维落地,通过后54号真实执行,不再退回改稿或重查样品。实际试吃/市场/量产均未做。新数字人接入、实名绑定及旧机器人密钥轮换仍为待办。')
+text += f'\nS5固定材料包:ksf-S5-classroom-next-20261103.zip,{receipt["bytes"]}字节,SHA256={receipt["sha256"]};11条目、清单10/10。\n'
+progress_file.write_text(text, encoding='utf-8', newline='\n')
+
+pending_file = DOCS / '02-后续待补资料.md'
+text = pending_file.read_text(encoding='utf-8')
+old = next(line for line in text.splitlines() if line.startswith('软件流程下一步:'))
+text = text.replace(old, '软件流程下一步:S5草稿已按52号审阅定稿,原八项缺口加五项设计问题共13条;规格数值null、formulaItems/productionSteps为空。53号+S5固定材料包交运维只读准备,通过后54号由王刚001真实执行。无需重复改稿或要求用户先供实物。本轮系统仍S4、concept与S5报告均未保存;拟执行后保持S5/in_progress,课堂研究未决项继续保留,不自动完成或正式发布。')
+pending_file.write_text(text, encoding='utf-8', newline='\n')
+print(json.dumps(receipt, ensure_ascii=True, indent=2))

+ 107 - 0
scripts/package-S5-completion.py

@@ -0,0 +1,107 @@
+"""Package verified closeout instructions and update only local progress docs."""
+from pathlib import Path
+from datetime import datetime, timezone
+import hashlib
+import json
+import zipfile
+
+ROOT = Path(__file__).resolve().parents[1]
+DOCS = Path('F:/文档/工作坊当前资料')
+OUT = ROOT / 'work/S5-classroom-completion-20261103'
+PACKAGE = DOCS / 'ksf-S5-classroom-completion-20261103.zip'
+BACKUP = ROOT / 'work/S5-progress-before-completion-preparation-20261103'
+audit = json.loads((ROOT / 'work/S5-completion-live-preflight-20261103.json').read_text(encoding='utf-8'))
+simulation = json.loads((ROOT / 'work/S5-completion-isolated-simulation-20261103.json').read_text(encoding='utf-8'))
+assert audit['ok'] and simulation['ok']
+assert audit['productionBusinessWrites'] == simulation['productionWrites'] == 0
+assert audit['checksPassed'] == simulation['checksPassed'] == 9
+assert audit['currentStage'] == 'S5' and audit['progressState'] == 'in_progress'
+assert not PACKAGE.exists() and not BACKUP.exists()
+sha = lambda data: hashlib.sha256(data).hexdigest()
+old_names = ['52-S5桌面方案审阅与定稿.md', '53-发给运维001-S5方案与报告准备.txt', '54-发给王刚001-S5方案与课堂报告保存.txt', '55-S5方案与课堂报告独立验收结果.md', 'ksf-S5-classroom-next-20261103.zip']
+old_hashes = {n: sha((DOCS / n).read_bytes()) for n in old_names}
+
+preflight_path = OUT / 'preflight.json'
+preflight = json.loads(preflight_path.read_text(encoding='utf-8'))
+preflight.pop('localStructureOnly')
+preflight.update({
+    'scope': 'Local material preparation plus separately recorded production reads and RAM simulation',
+    'liveReadOnlyPreflight': {'at': audit['at'], 'checksPassed': audit['checksPassed'], 'readCalls': audit['readCalls'], 'currentStage': 'S5', 'progressState': 'in_progress', 'artifactCount': 3, 'eventCount': 7, 'pendingQuestionCount': 13, 'futureWriteAuditMatches': 0, 'productionBusinessWrites': 0},
+    'isolatedSimulation': {'at': simulation['at'], 'checksPassed': simulation['checksPassed'], 'commandSteps': 3, 'negativeControls': simulation['negativeControls'], 'idempotencyControls': simulation['idempotencyControls'], 'productionCalls': 0, 'productionWrites': 0, 'expectedAfter': simulation['expectedAfter']},
+    'currentMessageIdentityVerifiedByCodex': False, 'remoteFilesystemAndProcessesInspected': False,
+})
+preflight_path.write_text(json.dumps(preflight, ensure_ascii=False, indent=2) + '\n', encoding='utf-8', newline='\n')
+with (OUT / '55-S5方案与课堂报告独立验收结果.md').open('xb') as stream:
+    stream.write((DOCS / '55-S5方案与课堂报告独立验收结果.md').read_bytes())
+with (OUT / '56-课堂总结与完成条件.md').open('a', encoding='utf-8', newline='\n') as stream:
+    stream.write('''
+
+独立准备结果:本轮生产只读预检9项通过,实际发起9次只读调用,项目仍S5/in_progress、3成果/1概念/7事件、13问题;未来完成写键没有成功审计。当前部署的转阶段和状态读取命令及相关会话/事件/幂等处理与核查实现一致。Codex未独立核远端文件/进程或本条企微身份。
+
+用本次捕获的部署代码和字段表完成本地内存模拟9项:3步CLI流程、3个前提反向控制、2个幂等控制通过。模拟完成后仍3报告/1概念/13问题,只增加第8条completed事件与1条成功审计。模拟重放仅在隔离内存进行,不允许据此自动重试生产。没有生产完成写入或本轮生产浏览器完成态验收;后续仍按57/58执行和回读。
+
+独立证据:F:/kangshifu-board/work/S5-completion-live-preflight-20261103.json、F:/kangshifu-board/work/S5-completion-isolated-simulation-20261103.json。历史55号及其首次失败/后续验收证据保持原样。
+''')
+with (OUT / 'README.txt').open('a', encoding='utf-8', newline='\n') as stream:
+    stream.write('已完成9项生产只读预检(9次只读调用)及9项内存模拟;未执行生产完成写入。57号远端落地预检和58号真实消息执行仍待完成。\n')
+
+names = sorted(p.name for p in OUT.iterdir() if p.is_file())
+assert len(names) == 8
+for name in names:
+    text = (OUT / name).read_text(encoding='utf-8')
+    for forbidden in ['-----BEGIN', 'X-Parse-Master-Key', 'signingSecret']:
+        assert forbidden not in text
+manifest = ''.join(f'{sha((OUT / name).read_bytes())}  {name}\n' for name in names)
+with (OUT / 'FILES.sha256').open('x', encoding='utf-8', newline='\n') as stream:
+    stream.write(manifest)
+with zipfile.ZipFile(PACKAGE, 'x', compression=zipfile.ZIP_DEFLATED) as archive:
+    for name in [*names, 'FILES.sha256']:
+        archive.write(OUT / name, arcname=name)
+with zipfile.ZipFile(PACKAGE) as archive:
+    assert len(archive.infolist()) == 9
+    assert archive.testzip() is None
+    for name in names:
+        assert archive.read(name) == (OUT / name).read_bytes()
+        assert sha(archive.read(name)) == sha((OUT / name).read_bytes())
+
+for name in ['56-课堂总结与完成条件.md', '57-发给运维001-课堂完成准备.txt', '58-发给王刚001-课堂完成与回读.txt']:
+    assert not (DOCS / name).exists()
+    with (DOCS / name).open('xb') as stream:
+        stream.write((OUT / name).read_bytes())
+
+originals = {name: (DOCS / name).read_bytes() for name in ['01-工作进展汇报.txt', '02-后续待补资料.md', 'README.md']}
+BACKUP.mkdir()
+for name, content in originals.items():
+    with (BACKUP / name).open('xb') as stream:
+        stream.write(content)
+with (BACKUP / 'manifest.json').open('x', encoding='utf-8') as stream:
+    json.dump({name: sha(content) for name, content in originals.items()}, stream, ensure_ascii=False, indent=2)
+
+progress = originals['01-工作进展汇报.txt'].decode('utf-8-sig')
+old = '下一步可整理课堂总结与完成条件,再执行相应状态操作;本次没有自动completed。'
+assert old in progress
+progress = progress.replace(old, '课堂总结与完成条件已整理为56号,1份完成模板、57号运维单、58号王刚执行单及固定材料包已备齐;9项生产只读预检和9项隔离模拟通过。本轮仍S5/in_progress,没有完成写入。下一步先交57号+收尾包给运维落地预检,再由王刚001按58号真实消息执行1写2读,最终由Codex核完成态。', 1)
+progress += f'\n课堂收尾固定包:{PACKAGE.name},{PACKAGE.stat().st_size}字节,SHA256={sha(PACKAGE.read_bytes())};9条目、清单8/8核验。更新前快照:{BACKUP.as_posix()}/。\n'
+
+needs = originals['02-后续待补资料.md'].decode('utf-8-sig')
+old = '下一步可整理课堂总结与完成条件;不重跑53/54号,不补存新版本。'
+assert old in needs
+needs = needs.replace(old, '56号已整理课堂总结,57/58号及收尾固定包已准备并通过9项生产只读预检、9项内存模拟;当前仍S5/in_progress,未完成写入。先交57号与包运维落地,通过后58号由王刚001在真实消息执行1写2读。预期只新增completed事件/成功审计,13问题不变;不重跑53/54号,不补存新版本。', 1)
+
+readme = originals['README.md'].decode('utf-8-sig')
+old = '下一步可整理课堂总结和完成条件。'
+assert old in readme
+readme = readme.replace(old, '56号课堂总结、57/58号和收尾固定包已准备;9项生产只读预检、9项内存模拟通过,尚未执行完成写入。下一步交57号+包运维落地预检,再按58号真实执行1写2读,预期S5/completed并保留13问题。', 1)
+line = '- 55-S5方案与课堂报告独立验收结果.md:'
+assert line in readme
+readme = readme.replace(line, '- 56-课堂总结与完成条件.md:当前收尾范围,保留13问题、历史requestId偏差和研究/确认边界。\n- 57-发给运维001-课堂完成准备.txt:下一步;落地1份固定模板、核合同/归属/状态/回执/在途,不代跑完成。\n- 58-发给王刚001-课堂完成与回读.txt:57号通过后在真实消息执行3步;project-status→project-progress→project-status,1写2读,只更新课堂完成状态。\n- ksf-S5-classroom-completion-20261103.zip:9条目、清单8/8;模板两ID为null不可直接提交,生产未完成。\n' + line, 1)
+for name, text in {'01-工作进展汇报.txt': progress, '02-后续待补资料.md': needs, 'README.md': readme}.items():
+    assert '仍S5/in_progress' in text or 'S5 / in_progress' in text
+    (DOCS / name).write_text(text, encoding='utf-8', newline='\n')
+
+for name, digest in old_hashes.items():
+    assert sha((DOCS / name).read_bytes()) == digest
+receipt = {'ok': True, 'at': datetime.now(timezone.utc).isoformat(), 'operation': 'prepare-S5-classroom-completion', 'package': str(PACKAGE), 'bytes': PACKAGE.stat().st_size, 'sha256': sha(PACKAGE.read_bytes()), 'entries': 9, 'manifestFilesVerified': 8, 'templateCount': 1, 'readOnlyPreflightChecks': 9, 'productionReadCalls': 9, 'isolatedSimulationChecks': 9, 'productionBusinessWrites': 0, 'currentStage': 'S5', 'progressState': 'in_progress', 'completionExecuted': False, 'backup': str(BACKUP), 'oldFilesUnchanged': old_hashes}
+with (ROOT / 'work/S5-completion-package-receipt-20261103.json').open('x', encoding='utf-8') as stream:
+    json.dump(receipt, stream, ensure_ascii=False, indent=2)
+print(json.dumps(receipt, ensure_ascii=True, indent=2))

+ 114 - 0
scripts/package-complete-release.py

@@ -0,0 +1,114 @@
+"""Freeze the complete committed release; database transport is delivered separately."""
+import argparse
+import hashlib
+import io
+import json
+from pathlib import Path
+import re
+import subprocess
+import zipfile
+
+ROOT = Path(__file__).resolve().parents[1]
+RELEASE = 'ksf-complete-deploy-20261106-r1'
+sha = lambda b: hashlib.sha256(b).hexdigest()
+
+def main():
+    parser = argparse.ArgumentParser(description=__doc__)
+    parser.add_argument('--commit', default='HEAD')
+    args = parser.parse_args()
+    commit = subprocess.check_output(['git', 'rev-parse', args.commit + '^{commit}'], cwd=ROOT).decode().strip()
+    out = ROOT / 'artifacts/全部更新部署-20261106'
+    work = ROOT / 'work' / RELEASE
+    package = work / 'package'
+    archive = out / (RELEASE + '.zip')
+    if archive.exists() or package.exists():
+        raise ValueError('RELEASE_ALREADY_EXISTS')
+    frozen = zipfile.ZipFile(io.BytesIO(subprocess.check_output(['git', 'archive', '--format=zip', commit], cwd=ROOT)))
+    baseline = json.loads(frozen.read('deploy/board-files-20261106.json'))
+    plan = json.loads(frozen.read('deploy/complete-release-plan.json'))
+    for prior in plan['previousPushedCommits']:
+        subprocess.run(['git', 'merge-base', '--is-ancestor', prior['commit'], commit], cwd=ROOT, check=True)
+    static = {f['path'] for f in baseline['files']}
+    package.mkdir(parents=True)
+    for name in frozen.namelist():
+        if name.endswith('/'):
+            continue
+        if name in static or name == 'package.json' or name.startswith(('server/', 'skills/', 'scripts/', 'integrations/', 'test/')):
+            target = package / 'payload' / name
+            target.parent.mkdir(parents=True, exist_ok=True)
+            target.write_bytes(frozen.read(name))
+    for f in baseline['files']:
+        assert sha((package / 'payload' / f['path']).read_bytes()) == f['sha256'], 'STATIC_CONTENT_CHANGED_SINCE_BASELINE'
+    baseline.update(commit=commit, release=RELEASE)
+    (package / 'deploy').mkdir()
+    (package / 'deploy/board-files.json').write_text(json.dumps(baseline, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
+    (package / 'deploy/complete-release-plan.json').write_bytes(frozen.read('deploy/complete-release-plan.json'))
+    (package / 'deploy/complete-release-validation.json').write_bytes(frozen.read('deploy/complete-release-validation.json'))
+    publisher = frozen.read('scripts/publish-opportunity-ui.mjs').decode('utf-8')
+    start = publisher.index('export const FILES=Object.freeze([')
+    end = publisher.index(']);', start) + 3
+    publisher = publisher[:start] + 'export const FILES=Object.freeze(' + json.dumps([f['path'] for f in baseline['files']]) + ');' + publisher[end:]
+    publisher = publisher.replace('ksf.opportunity-ui.publish.v1', 'ksf.board.publish.v1').replace('ksf.opportunity-ui.20261103.v1', RELEASE)
+    publisher = publisher.replace('filesChecked:6', 'filesChecked:FILES.length').replace('ksf-opportunity-ui', 'ksf-board')
+    publisher = publisher.replace("sourceDir:root,manifestPath:path.join(root,'deploy/opportunity-ui-files-20261103.json')", "sourceDir:path.join(root,'payload'),manifestPath:path.join(root,'deploy/board-files.json')")
+    (package / 'scripts').mkdir()
+    (package / 'scripts/publish-board.mjs').write_text(publisher, encoding='utf-8', newline='\n')
+    node = 'node'
+    subprocess.run([node, '--check', str(package / 'scripts/publish-board.mjs')], check=True, capture_output=True)
+    subprocess.run([node, 'server/scripts/build-member-groups.mjs'], cwd=package / 'payload', check=True, capture_output=True)
+    docs = {
+        '完整发布执行单.md': 'docs/board-deploy-20261106/完整发布执行单.md',
+        '看板部署细则.md': 'docs/board-deploy-20261106/发给运维001-部署已推送看板.md',
+        '数据库分包投递.md': 'docs/voc-database-delivery-20261106/发给运维001-数据库分包投递.md',
+        '王刚001-数据库就绪后验收.md': 'docs/voc-database-delivery-20261106/给王刚001-数据库就绪后继续验收.md',
+    }
+    for name, source in docs.items():
+        body = frozen.read(source).decode('utf-8')
+        if name == '完整发布执行单.md':
+            body = f'> 本包固定源码提交:{commit}\n\n' + body
+        (package / name).write_text(body, encoding='utf-8', newline='\n')
+    functions = json.loads((package / 'payload/server/dist/member-groups/manifest.json').read_text())
+    skills = {}
+    for folder in (package / 'payload/skills').iterdir():
+        manifest = folder / 'FILES.sha256'
+        for line in manifest.read_text(encoding='utf-8').splitlines():
+            h, rel = line.split('  ', 1)
+            assert sha((folder / rel).read_bytes()) == h, 'SKILL_CHECKSUM_MISMATCH'
+        skills[folder.name] = {'manifestSha256': sha(manifest.read_bytes()), 'filesVerified': len(manifest.read_text().splitlines())}
+    imported = 0
+    for relative in static:
+        p = package / 'payload' / relative
+        if p.suffix != '.js':
+            continue
+        for m in re.finditer(r'''(?:from|import)\s*\(?\s*['"](\.[^'"]+)['"]''', p.read_text(encoding='utf-8')):
+            target = (p.parent / m[1]).resolve()
+            assert target.is_relative_to((package / 'payload/src').resolve()) and target.is_file(), 'MODULE_DEPENDENCY_MISSING'
+            imported += 1
+    expected_files = [p for p in package.rglob('*') if p.is_file()]
+    assert not any(p.suffix.lower() in {'.sqlite', '.db', '.zip', '.pem', '.key', '.pyc'} or p.name.startswith('.env') or p.name == 'credential-binding.json' for p in expected_files)
+    assert not (package / 'payload/index.html').exists()
+    release = {'release': RELEASE, 'sourceCommit': commit, 'previousPushedCommits': plan['previousPushedCommits'],
+               'staticFiles': len(static), 'relativeImportsVerified': imported, 'functions': functions,
+               'skills': skills, 'databaseIncluded': False, 'credentialsIncluded': False,
+               'databaseTransportRequired': ['ksf-voc-database-20261106-r1-part01.zip', 'ksf-voc-database-20261106-r1-part02.zip'],
+               'productionInstalled': False, 'browserVerified': False, 'productionWrites': 0}
+    (package / 'RELEASE.json').write_text(json.dumps(release, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
+    all_files = sorted(p for p in package.rglob('*') if p.is_file())
+    (package / 'FILES.sha256').write_text(''.join(sha(p.read_bytes()) + '  ' + p.relative_to(package).as_posix() + '\n' for p in all_files), encoding='utf-8', newline='\n')
+    out.mkdir(parents=True, exist_ok=True)
+    with zipfile.ZipFile(archive, 'x', zipfile.ZIP_DEFLATED, compresslevel=9) as z:
+        for p in [*all_files, package / 'FILES.sha256']:
+            z.write(p, p.relative_to(package).as_posix())
+    with zipfile.ZipFile(archive) as z:
+        assert z.testzip() is None
+        for line in z.read('FILES.sha256').decode('utf-8').splitlines():
+            h, name = line.split('  ', 1)
+            assert sha(z.read(name)) == h
+    receipt = {'ok': True, 'filename': archive.name, 'bytes': archive.stat().st_size,
+               'sha256': sha(archive.read_bytes()), 'entries': len(all_files) + 1, 'filesVerified': len(all_files), **release}
+    (out / 'delivery-receipt.json').write_text(json.dumps(receipt, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
+    (out / '完整发布执行单.md').write_bytes((package / '完整发布执行单.md').read_bytes())
+    print(json.dumps(receipt, ensure_ascii=False, indent=2))
+
+if __name__ == '__main__':
+    main()

+ 55 - 0
scripts/package-current-employee-prep.py

@@ -0,0 +1,55 @@
+"""Build a review-only integration handoff. Never installs or touches a gateway."""
+from pathlib import Path
+from io import BytesIO
+import hashlib
+import json
+import zipfile
+
+ROOT = Path(__file__).resolve().parent.parent
+DEST = ROOT / 'work/delivery-by-recipient-20261102'
+NAME = 'ksf-current-employee-integration-prep-20261102-v2'
+skill = ROOT / 'skills/ksf-workshop-runtime'
+payloads = {}
+rows = (skill / 'FILES.sha256').read_text(encoding='utf-8').splitlines()
+assert len(rows) == 10
+for row in rows:
+    fingerprint, rel = row.split('  ', 1)
+    b = (skill / rel).read_bytes()
+    assert hashlib.sha256(b).hexdigest() == fingerprint
+    payloads['skills/ksf-workshop-runtime/' + rel] = b
+payloads['skills/ksf-workshop-runtime/FILES.sha256'] = (skill / 'FILES.sha256').read_bytes()
+files = [
+    'scripts/install-workshop-skill.mjs',
+    'integrations/wecom-identity/workshop_registration.py',
+    'integrations/wecom-identity/__init__.py',
+    'integrations/wecom-identity/registration.example.json',
+    'test/workshop-identity.test.js', 'test/workshop-registration.test.py',
+    'docs/wecom-current-employee-registration.md', 'docs/aibot-adapter-v3-review-20261102.md',
+]
+for rel in files: payloads[rel] = (ROOT / rel).read_bytes()
+# identity JS suite imports runWorkshop, which is part of the skill payload.
+for name in ('给运维001-v3复核与接口.txt', '给王刚001-v3阶段回复.txt'):
+    payloads[name] = (DEST / name).read_bytes()
+for rel, b in payloads.items():
+    assert 'credential-binding' not in rel and not rel.endswith(('.pem', '.key', '.env'))
+    assert b'-----BEGIN PRIVATE KEY-----' not in b
+payloads['FILES.sha256'] = ''.join(hashlib.sha256(payloads[k]).hexdigest() + '  ' + k + chr(10) for k in sorted(payloads)).encode()
+memory = BytesIO()
+with zipfile.ZipFile(memory, 'w', zipfile.ZIP_DEFLATED) as z:
+    for rel, b in sorted(payloads.items()):
+        z.writestr(zipfile.ZipInfo(rel, (2026, 11, 2, 0, 0, 0)), b, compress_type=zipfile.ZIP_DEFLATED)
+packed = memory.getvalue()
+with zipfile.ZipFile(BytesIO(packed)) as z:
+    assert z.testzip() is None
+    for line in z.read('FILES.sha256').decode().splitlines():
+        fingerprint, rel = line.split('  ', 1)
+        assert hashlib.sha256(z.read(rel)).hexdigest() == fingerprint
+DEST.mkdir(parents=True, exist_ok=True)
+target = DEST / (NAME + '.zip')
+if target.exists(): assert target.read_bytes() == packed, 'REFUSE_OVERWRITE_DIFFERENT_PACKAGE'
+else: target.write_bytes(packed)
+receipt = {'file': str(target), 'sizeBytes': len(packed), 'sha256': hashlib.sha256(packed).hexdigest(),
+           'outerManifestEntries': len(payloads) - 1, 'skillManifestEntries': len(rows),
+           'purpose': 'isolated integration review only', 'installed': False, 'deployed': False, 'containsCredentials': False}
+(DEST / (NAME + '.json')).write_text(json.dumps(receipt, ensure_ascii=False, indent=2) + chr(10), encoding='utf-8')
+print(json.dumps(receipt, ensure_ascii=False, indent=2))

+ 206 - 0
scripts/package-current-product-observations.py

@@ -0,0 +1,206 @@
+"""Verify the supplied capture archive and prepare two fixed inputs; no network or DB access."""
+from pathlib import Path
+import collections
+import hashlib
+import json
+import zipfile
+
+ROOT = Path(__file__).resolve().parents[1]
+ARCHIVE = ROOT / 'work/current-product-archive-20261102.zip'
+CAPTURE = ROOT / 'work/current-product-archive-20261102'
+DEST = ROOT / 'work/current-product-observations-20261102'
+DOCS = Path(r'F:\文档\工作坊当前资料')
+EXPECTED_ARCHIVE = '0ec14dcccbce26bc0fc1a368bfaef4bbc32ea6f0b651ae31df5dd28a8bc0e7a5'
+TASK = 'KSF-商品页面观察-20261102-01'
+REPORTED_TIME = '2026-11-02 19:11–19:13 +08:00(采集者回报范围;无精确时间原始日志)'
+sha = lambda b: hashlib.sha256(b).hexdigest()
+encode = lambda obj: (json.dumps(obj, ensure_ascii=False, indent=2) + '\n').encode('utf-8')
+
+
+def retain(path, value):
+    if path.exists():
+        assert path.read_bytes() == value, f'Preserve different existing file: {path}'
+    else:
+        path.parent.mkdir(parents=True, exist_ok=True)
+        with path.open('xb') as out:
+            out.write(value)
+
+
+def main():
+    archive = ARCHIVE.read_bytes()
+    assert len(archive) == 614046 and sha(archive) == EXPECTED_ARCHIVE
+    manifest = json.loads((CAPTURE / 'originals-manifest.json').read_text('utf-8'))
+    assert len(manifest) == 4 and len({m['file'] for m in manifest}) == 4
+    files = []
+    for entry in manifest:
+        assert Path(entry['file']).name == entry['file']
+        blob = (CAPTURE / 'originals' / entry['file']).read_bytes()
+        assert len(blob) == entry['size'] and sha(blob) == entry['sha256']
+        files.append({'file': entry['file'], 'bytes': len(blob), 'sha256': sha(blob), 'matched': True})
+    by_name = {entry['file']: entry for entry in files}
+    card_blob = (CAPTURE / 'originals/suning-card.html').read_bytes()
+    card_text = card_blob.decode('utf-8')
+    card = json.loads(card_text)
+    assert len(card) == 1 and set(card[0]) == {'html', 'text'}
+    assert len(card_text) == 4341 and len(card_blob) == 4681
+    widths = collections.Counter(len(c.encode('utf-8')) for c in card_text)
+    assert widths == {1: 4170, 2: 2, 3: 169}
+    assert len(card[0]['html']) == 4000 and card[0]['html'].endswith('aria-hidden="tru')
+    suning = card[0]['text']
+    huajie = (CAPTURE / 'originals/huajie-5904.txt').read_text('utf-8')
+    quote_start = huajie.index('新品 康师傅 红烧牛肉拌面115g')
+    quote_end = huajie.index('进入店铺', quote_start) + len('进入店铺')
+    huajie_quote = huajie[quote_start:quote_end]
+    assert huajie.count('$') == 23
+    assert all(word not in huajie for word in ['USD', '美元', '人民币'])
+    review = {
+        'ok': True, 'operation': 'current-product-archive-independently-reviewed',
+        'archiveSha256': EXPECTED_ARCHIVE, 'archiveBytes': len(archive), 'archiveEntries': 8,
+        'originalsVerified': files, 'screenshotsVisuallyReviewed': True,
+        'suningSize': {'bytes': len(card_blob), 'characters': len(card_text),
+                       'utf8WidthCounts': dict(widths),
+                       'explanation': '169 three-byte characters add 338 bytes; two two-byte yen signs add 2 bytes; total difference 340',
+                       'historicalFirstToolOutputAvailable': False},
+        'suningFormat': {'container': 'JSON array with html and text', 'htmlCharacters': 4000,
+                          'htmlFragmentTruncated': True, 'completePageHtml': False},
+        'huajieCurrency': 'dollar sign only; currency and market region not verified',
+        'missingSizeNote': {'presentInPackage': False, 'removalActorOrIntentVerified': False,
+                            'originalsCurrentIntegrityBlocked': False},
+        'verifiedScope': 'archived page display observations; not current stock, fulfilled orders or verified SKU parameters',
+        'readOnly': True, 'networkRequests': 0, 'businessWrites': 0,
+    }
+    retain(ROOT / 'work/current-product-archive-review-20261102.json', encode(review))
+
+    def task(key, url, platform, title, raw, quote, original, screenshot, limitations):
+        begin = raw.index(quote)
+        record = {
+            'itemKey': key + '-page-observation', 'className': 'KfEvidence',
+            'externalKey': TASK + ':' + key + ':page-observation:v1',
+            'data': {
+                'dataMode': 'live', 'quote': quote,
+                'evidenceKind': 'retailer_listing_observation',
+                'platform': platform, 'quoteForm': 'verbatim_excerpt',
+                'verificationState': 'archived_text_and_screenshot_checked',
+                'collectedAt': None,
+                'locator': {
+                    'url': url, 'section': '已归档商品卡文字' if key == 'suning' else '已归档店铺商品页面文字',
+                    'startCharacter': begin, 'endCharacter': begin + len(quote), 'unit': 'Unicode code point',
+                    'observedAtReported': REPORTED_TIME, 'exactCaptureTimestampVerified': False,
+                    'archiveSha256': EXPECTED_ARCHIVE,
+                    'textOrigin': {'file': original, 'sha256': by_name[original]['sha256'],
+                                   'extraction': 'JSON[0].text' if key == 'suning' else 'UTF-8 text file'},
+                    'screenshot': {'file': screenshot, 'sha256': by_name[screenshot]['sha256']},
+                    'limitations': limitations,
+                    **({'productLink': 'https://product.suning.com/0000000000/12424158513.html',
+                        'deliveryRegionVisible': '江西南昌'} if key == 'suning' else {}),
+                },
+            },
+        }
+        assert raw[begin:begin + len(quote)] == quote
+        return {
+            'source': {'title': title, 'sourceKind': 'ecommerce', 'dataMode': 'live',
+                       'sourceUrl': url, 'platform': platform, 'rawText': raw, 'collectedAt': None},
+            'records': [record],
+        }
+
+    inputs = {
+        'suning.input.json': task(
+            'suning', 'https://search.suning.com/%E5%BA%B7%E5%B8%88%E5%82%85%E6%8B%8C%E9%9D%A2/',
+            'suning', '苏宁搜索商品卡观察(2026-11-02;规格与库存未核实)', suning, suning,
+            'suning-card.html', 'kangshifu-dangqian-suning-list.png',
+            ['页面展示不证明有货、可下单或当前在售。', '17.70元对应件数未知,不是已核实单碗价或成交价。',
+             '领券200-10只是商品卡提示,适用范围和优惠门槛未独立核实。',
+             '标题与主图不能补足净含量、组合件数、配料或营养参数;不创建或关联既有产品/SKU。',
+             '100+评价只是页面计数提示,没有取得消费者逐字评价。',
+             'HTML片段已截断;source.rawText仅来自随包JSON导出的text字段。']),
+        'huajie.input.json': task(
+            'huajie', 'https://www.huajiemarket.com/goods/info/5904', 'huajiemarket',
+            '华姐零食铺商品页面观察(2026-11-02;币种与市场地区未核实)', huajie, huajie_quote,
+            'huajie-5904.txt', 'kangshifu-dangqian-huajie-5904.png',
+            ['4.59与5.99使用$符号;具体币种未知,不按美元或人民币换算。',
+             '市场地区和配送地区未核实,不作为中国国内在售或价格对比证据。',
+             '115g只是标题标示;碗形仅为主图图示,非规格参数已核实;无出口包装证据。',
+             '有效期和加入购物车入口只记录页面文字,不证明实物批次、库存或完成交易。',
+             '营销文案属于店铺描述,不是消费者反馈;不创建或关联既有产品/SKU。']),
+    }
+    for name, value in inputs.items():
+        retain(DEST / name, encode(value))
+
+    review_text = '''当前商品原件核对结果(2026年11月2日)
+
+附件614,046字节,包SHA-256与运维回报一致;8个文件齐全,四份原件的大小与完整哈希4/4吻合。Codex已实际查看两张截图并读取文字。
+
+苏宁截图及商品卡文字支持商品标题、17.70元页面报价、苏宁自营标记、券提示和江西南昌收货地区;不支持净含量、组合件数、单碗价、成交价或当前库存。
+
+华姐页面支持标题中的115g、$4.59/$5.99、有效期文字与店铺名称;币种和市场地区未核实。商品主图呈碗形,仅保留为图片观察,不当作规格参数或出口包装证明。
+
+大小差异已独立复现:suning-card.html为4,681字节、4,341个字符。补充说明的字符分类需纠正:实际为169个三字节字符和两个两字节的¥符号,多出169×2+2×1=340字节。可解释当前数字差异,但没有首次工具输出,不能反推全部历史。
+
+文件格式也应准确记录:名为.html的文件实际是含html/text的JSON数组,其中html为4,000字符的截断片段,不是完整网页。现有商品文字仍可与截图互相核对,来源入库时只用实际取得的text字段。
+
+SIZE-DISCREPANCY-NOTE.md不在交付包中,Codex未操作远端目录,无法判断是谁删除、为何删除或内容是否合并;记录为未确认的过程备注。它不影响当前四份原件与清单的核对,不据此断言历史完整。
+
+结论:归档原件核对通过,适合保存为两条独立的“商品页面观察”来源及证据。当前在售、SKU规格和可比较单价仍未核实。固定输入已准备并在本地验证后交付;实际线上入库、产品创建和报告更新均未执行。
+'''
+    retain(DOCS / '11-当前商品原件核对结果.txt', review_text.encode('utf-8'))
+
+    ops = '''运维001:准备两条商品页面观察入库。只落地固定输入与留存目录,不代跑业务。
+
+包:ksf-page-observations-20261102.zip。核FILES.sha256全部项。
+将inputs/suning.input.json、inputs/huajie.input.json放入/opt/data/ksf-executor-20261102/page-observations-01/,root:root 0644、hermes可读;保持内容和文件名不变。沿用现有ksf-secure.mjs,不装新执行器、不改Skill/Function、不重启。
+
+归档原件继续留在原位置;包只包含已核对的固定输入和核对说明,不替换原件。输入仅涉及KfSource+KfEvidence,无KfCategory、KfProduct、会话或报告写入。
+
+确认本任务键KSF-商品页面观察-20261102-01的历史与在途;如已存在相同记录,保留不删,回读后按实际created/unchanged计数。发现已有该任务执行回执或在途则停止交付执行,不换键重跑。
+以hermes(10000)、HOME=/opt/data建立全新留存目录/opt/data/ksf-workshop-private/page-observations/KSF-page-observations-20261102-01(父目录与目标0700)。目标如已存在,不清空也不另换目录,先核查已有状态。
+
+回报输入哈希与权限、历史/在途核查和留存目录状态。随后交王刚001在一条新的真实消息任务里执行一次;运维不代跑。无需新建身份或复制员工/会话配置。
+'''
+    wang = '''王刚001:运维准备完成后,使用你本条消息已核实的现有agentProfileKey,通过现有secure入口导入两份固定页面观察输入。输入已经由Codex逐文件及截图核对,不需要你设计或改写。
+
+固定任务键KSF-商品页面观察-20261102-01。先确认无该任务执行回执/在途。以hermes(10000)、HOME=/opt/data执行;本步授权是资料入库,不是当前在售、SKU或价格研究已完成。
+
+第一份(苏宁):
+node /opt/data/skills/ksf-workshop-runtime/scripts/ksf-secure.mjs workshop-dedup --input /opt/data/ksf-executor-20261102/page-observations-01/suning.input.json --request-id KSF-商品页面观察-20261102-01:suning:preflight:v1
+只读查重成功后执行一次:
+node /opt/data/skills/ksf-workshop-runtime/scripts/ksf-secure.mjs workshop-import --agent-profile <当前已核实agentProfileKey> --input /opt/data/ksf-executor-20261102/page-observations-01/suning.input.json --request-id KSF-商品页面观察-20261102-01:suning:import:v1
+
+先确认外层ok和entryResult.ok均为true,取得entryResult.result里的sourceId、items[0].objectId和operation。只创建KfEvidence条目;不建产品或分类。输入的collectedAt=null是有意保留:原件只留采集者回报的观察时间范围,范围在locator.observedAtReported,不伪造精确时间。
+
+用返回的sourceId和evidenceId分别写只读输入{"className":"KfSource","objectId":"<实际sourceId>"}及{"className":"KfEvidence","objectId":"<实际evidenceId>"}到留存目录(0600、独占创建),调用:
+node /opt/data/skills/ksf-workshop-runtime/scripts/ksf-secure.mjs workshop-read --input <上述绝对路径> --request-id KSF-商品页面观察-20261102-01:suning:read-source:v1
+另一条evidence回读使用同格式命令及requestId KSF-商品页面观察-20261102-01:suning:read-evidence:v1。
+确认来源rawText/sourceKind=ecommerce/dataMode=live/平台/URL与输入相同;证据quote/locator/evidenceKind/verificationState与输入逐项一致、sourceRef指回本次来源、productRef为空、collectedAt为空。
+
+苏宁导入与两项回读均通过后,才处理第二份华姐页面:上述各命令的输入文件换为huajie.input.json,requestId里的suning统一换为huajie,其余流程相同。两个来源分开保存,不合并商品。
+
+所有输入、stdout/stderr、退出码、两层调用回执独占留存到/opt/data/ksf-workshop-private/page-observations/KSF-page-observations-20261102-01,0600。任何一步失败即停,包括查重或回读失败,不自动重试/换键/删记录;回报已取得编号。错误不代表零写入。
+
+成功回报每页sourceId、evidenceId、jobId、sourceOperation、created/unchanged、import两层invocationId及精确回读结果。相同资料unchanged属正常,不为增加数量换键。没有新产品、SKU、会话、报告或新版S1是预期;G8IrkkDG7X保持原样,不重跑旧任务。不得说“已核实当前在售”“已验证单碗价”“采集了消费者评价”或“美元价”。
+'''
+    retain(DOCS / '12-发给运维001-商品页面观察准备.txt', ops.encode('utf-8'))
+    retain(DOCS / '13-发给王刚001-商品页面观察入库.txt', wang.encode('utf-8'))
+    package = {**{'inputs/' + name: encode(value) for name, value in inputs.items()},
+               'archive-review.json': encode(review), '核对结果.txt': review_text.encode('utf-8'),
+               '给运维001.txt': ops.encode('utf-8'), '给王刚001.txt': wang.encode('utf-8')}
+    package['FILES.sha256'] = ''.join(sha(blob) + '  ' + name + '\n' for name, blob in package.items()).encode()
+    target = DOCS / 'ksf-page-observations-20261102.zip'
+    assert not target.exists(), 'Preserve existing package'
+    with zipfile.ZipFile(target, 'x', compression=zipfile.ZIP_DEFLATED) as z:
+        for name, blob in package.items():
+            z.writestr(name, blob)
+    with zipfile.ZipFile(target) as z:
+        assert z.testzip() is None
+        for line in z.read('FILES.sha256').decode().splitlines():
+            expected, name = line.split('  ', 1)
+            assert sha(z.read(name)) == expected
+    receipt = {'file': str(target), 'sha256': sha(target.read_bytes()), 'bytes': target.stat().st_size,
+               'manifestChecked': '6/6', 'taskKey': TASK, 'sourcesPlanned': 2, 'evidencePlanned': 2,
+               'productsPlanned': 0, 'reportsPlanned': 0, 'businessWrites': 0, 'targetExecuted': False,
+               'inputHashes': {name: sha(encode(value)) for name, value in inputs.items()}}
+    retain(ROOT / 'work/current-product-observations-package-receipt.json', encode(receipt))
+    print(json.dumps(receipt, ensure_ascii=True, indent=2))
+
+
+if __name__ == '__main__':
+    main()

+ 61 - 0
scripts/package-dedup-acceptance.py

@@ -0,0 +1,61 @@
+"""Build the standalone sample dedup acceptance delivery without private inputs."""
+from pathlib import Path
+import hashlib
+import json
+import zipfile
+
+ROOT = Path(__file__).resolve().parents[1]
+DEST = ROOT / 'work/delivery-by-recipient-20261102'
+KEY = 'KSF-查重实测-20261102-01'
+OPS = '''运维001:查重验收选A,专用执行器已准备好。只落地脚本和输入,不重启、不改Skill/Function、不代跑业务。
+
+1. 在独立目录解包,核验FILES.sha256。对照本次交付ZIP指纹,不改清单。
+2. 目标仍是R730 / agent-node-u17879018108。执行器安装到 /opt/data/ksf-executor-20261102/workshop-dedup-acceptance.mjs,root:root 0644;用目标Node做 node --check。不要覆盖workshop-full-chain.mjs。
+3. 模板context-dedup.example.json的agentProfileKey=null故意不可运行。仅从已核实的context-K.json取agentProfileKey字段,或回读已核实当前Agent档案键;核对和现有registration.json一致,不新建身份。以hermes(10000)、HOME=/opt/data写 /opt/data/ksf-workshop-private/context-dedup-01.json,0600,字段仅version/taskKey/agentProfileKey。不输出完整上下文。
+4. 以hermes建父目录 /opt/data/ksf-workshop-private/dedup,0700;检查目标 /opt/data/ksf-workshop-private/dedup/KSF-dedup-20261102-01 不存在。有任何痕迹先核查,不删除目录重跑,不另换任务键,不碰full-chain-K。
+5. 落地后回报脚本SHA、语法检查、输入属主权限、状态目录不存在,交王刚001在真实消息任务中执行。你不在运维身份下执行入库。
+
+目标侧执行会产生一份sample来源/原料和两批导入审计。重复实体复用不代表数据库完全不增长。详情见docs/workshop-dedup-acceptance-20261102.md。
+'''
+WANG = '''王刚001:本次执行专用查重验收,任务键固定 KSF-查重实测-20261102-01。待运维完成落地后,在本条真实消息授权的任务中,以hermes(10000)、HOME=/opt/data只执行下面这一条命令一次:
+
+node /opt/data/ksf-executor-20261102/workshop-dedup-acceptance.mjs --input /opt/data/ksf-workshop-private/context-dedup-01.json --state-dir /opt/data/ksf-workshop-private/dedup/KSF-dedup-20261102-01 --execute
+
+脚本固定样例内容,只换两轮requestId和externalKey;不创建工作坊会话或报告、不重跑K轮、不手工构造身份。首次导入预期created=1,第二次created=0/unchanged=1,且sourceId/materialId前后相同。unchanged就是本次要验证的复用。
+
+成功回报:ok、operation、taskKey,两轮sourceId/materialId/jobId/created/unchanged,两次import invocationId及状态目录。不贴完整输入/日志/账号。成功operation应为sample-material-dedup-verified。
+
+失败立即停,保留原键、目录和全部回执,不自动重试、不删记录、不换目录重跑;报最后一步、错误码及已返回编号。错误不等于零写入,超时也可能已入库。当前仅授权本样例验收,不宣称真实平台采集或正式报告通过。
+'''
+
+def main():
+    DEST.mkdir(parents=True, exist_ok=True)
+    files = {
+        'scripts/workshop-dedup-acceptance.mjs': (ROOT / 'scripts/workshop-dedup-acceptance.mjs').read_bytes(),
+        'docs/workshop-dedup-acceptance-20261102.md': (ROOT / 'docs/workshop-dedup-acceptance-20261102.md').read_bytes(),
+        'context-dedup.example.json': (json.dumps({'version': 'ksf.dedup-acceptance.v1', 'taskKey': KEY, 'agentProfileKey': None}, ensure_ascii=False, indent=2) + '\n').encode(),
+        '给运维001-查重验收落地.txt': OPS.encode(),
+        '给王刚001-查重验收执行.txt': WANG.encode(),
+    }
+    digest = lambda b: hashlib.sha256(b).hexdigest()
+    files['FILES.sha256'] = ''.join(f'{digest(b)}  {name}\n' for name,b in files.items()).encode()
+    target = DEST / 'ksf-dedup-acceptance-20261102.zip'
+    with zipfile.ZipFile(target, 'w', compression=zipfile.ZIP_DEFLATED) as z:
+        for name,b in files.items():
+            z.writestr(name,b)
+    with zipfile.ZipFile(target) as z:
+        assert z.testzip() is None
+        assert len(z.namelist()) == 6
+        for line in z.read('FILES.sha256').decode().splitlines():
+            sha,name = line.split('  ',1)
+            assert digest(z.read(name)) == sha
+        assert json.loads(z.read('context-dedup.example.json'))['agentProfileKey'] is None
+        assert z.read('scripts/workshop-dedup-acceptance.mjs') == files['scripts/workshop-dedup-acceptance.mjs']
+    for name in ['给运维001-查重验收落地.txt', '给王刚001-查重验收执行.txt']:
+        (DEST / name).write_bytes(files[name])
+    receipt = {'file': target.name, 'bytes': target.stat().st_size, 'sha256': digest(target.read_bytes()), 'manifestChecked': '5/5', 'archiveIntegrity': True, 'executorSha256': digest(files['scripts/workshop-dedup-acceptance.mjs']), 'containsCredentials': False, 'targetExecuted': False}
+    (DEST / 'ksf-dedup-acceptance-20261102.receipt.json').write_text(json.dumps(receipt, ensure_ascii=False, indent=2),encoding='utf-8')
+    print(json.dumps(receipt, ensure_ascii=False, indent=2))
+
+if __name__ == '__main__':
+    main()

+ 68 - 0
scripts/package-first-live-sources.py

@@ -0,0 +1,68 @@
+from pathlib import Path
+import hashlib
+import json
+import zipfile
+
+ROOT=Path(__file__).resolve().parents[1]
+SRC=ROOT/'work/first-live-sources-20261102'
+DEST=ROOT/'work/delivery-by-recipient-20261102'
+KEY='KSF-真实资料-20261102-01'
+OPS='''运维001:开始第一批真实官网资料入库准备。不是重跑K轮/查重样例,不改旧目录,不升级Skill/Function,不重启。
+
+1. 核验ZIP与FILES.sha256全部项。包内采集者为Codex:两页统一官网公开页面,取得时间、响应原始字节、解码HTML、全文和指纹均已留存。不是王刚001亲自抓取,也不是消费者调查。
+2. 建 /opt/data/ksf-executor-20261102/first-live-01,放入包内 scripts/workshop-first-live-import.mjs 和同目录 scripts/first-live-batch.json,两者root:root 0644、hermes可读。保持同目录,执行器内置批次JSON的SHA-256;不修改JSON、脚本或清单。node --check脚本。
+3. archives/与采集汇总.md归档到受控交付目录,保留原件与清单。包内不存在密钥或私有身份值。
+4. 用context-first-live.example.json生成 /opt/data/ksf-workshop-private/context-first-live-01.json;模板agentProfileKey=null不可原样安装,仅填现有已核实Agent档案键并与registration.json核对。hermes(10000)/HOME=/opt/data写,0600;不复制员工账号/会话键,不新登记身份。
+5. 以hermes建 /opt/data/ksf-workshop-private/first-live 父目录0700。检查目标 /opt/data/ksf-workshop-private/first-live/KSF-live-20261102-01 不存在,并查本任务键历史/在途;有痕迹则停下回读,不删除、换键或换目录。
+6. 回报脚本指纹/语法、批次JSON指纹、输入权限和无历史后,交王刚001在新真实消息任务中执行,不由运维冒跑。
+
+会写两份live来源,最多9条产品口味、9条证据和1个研究工作分类;以实际created/unchanged回执为准。不是9个SKU,也不保证在售。分类是明确标注的研究工作分类。9段引文来自两份官网口味/包装列表,不是9个独立来源或消费者评价。
+执行只做入库与回读,不创建会话/项目/报告、不触碰sample K记录。第一批成功后才基于真实返回sourceId做S1课堂小结。
+'''
+WANG='''王刚001:待运维落地完成后,在新真实消息授权任务中,以hermes(10000)、HOME=/opt/data执行一次:
+
+node /opt/data/ksf-executor-20261102/first-live-01/workshop-first-live-import.mjs --input /opt/data/ksf-workshop-private/context-first-live-01.json --state-dir /opt/data/ksf-workshop-private/first-live/KSF-live-20261102-01 --execute
+
+固定任务键KSF-真实资料-20261102-01。导入的是Codex已实际采集并归档的两份官网资料,dataMode=live;你执行归档资料导入,不声称自己刚在平台抓取。9个口味条目,分别是统一酱拌面4种和巧面馆拌面5种;附官网原文证据。
+
+预期operation=first-live-official-sources-imported,2来源/9产品口味/9证据/1研究工作分类并完成逐条回读。已有相同资料可unchanged,按实际回执报计数,不为凑新增换键。不是SKU粒度,不填未知净含量/价格/消费者评价。
+成功回报ok/operation/taskKey、两批sourceId、categoryId、各itemKey/className/objectId/operation及created/unchanged;保存完整两层调用回执与日志,不贴完整私有上下文。没有会话或报告是预期行为。
+失败立即停:报错误码、最后一步、已返回编号,保留目录和回执;不重试、不删记录、不换键、不重跑K轮,不手工造身份。入库完成后把回执交Codex核对,再组织S1真实资料小结和待补问题。
+'''
+
+def main():
+    bundle=json.loads((SRC/'batch.json').read_text(encoding='utf-8'))
+    lines=['# 第一批真实官网资料采集汇总','', '任务键:'+KEY, '', '本批由Codex通过HTTPS读取并归档,尚未写入目标业务库。只包含公开官网产品自述,不代表消费者研究、当前在售或独立验证。', '', '## 已取得资料', '', '| 产品口味条目 | 官网包装说明(系列级) | 来源 |', '|---|---|---|']
+    for f in bundle['facts']:
+        lines.append('|'+f['name']+'|'+f['packagingStatement']+'|'+f['sourceUrl']+'|')
+    lines+=['', '本批9个口味条目来自统一同一厂商的2个系列页,不是9个独立来源,也不是完整竞品样本。每个口味关联一段口味/包装列表引文;包装为系列级,不假设每种口味均有所有包装。未填写价格、净含量、配方比例、销量或消费痛点。', '', '## 来源归档']
+    for b in bundle['batches']:
+        lines+=['', '- '+b['capture']['url'], '- 取得时间:'+b['capture']['collectedAt'], '- 字符集:gb18030,严格解码;未知发布日期', '- 原始响应SHA-256:'+b['capture']['responseSha256'], '- 提取全文SHA-256:'+b['capture']['rawTextSha256']]
+    lines+=['', '## 下一步需要补充的细节', '']
+    lines += [str(i)+'. '+q for i,q in enumerate(bundle['pendingQuestions'],1)]
+    lines+=['', '建议先完成此批入库及独立回读,再补康师傅同口径产品、包装规格/渠道价格和逐字消费者评价,保存真实S1课堂小结。不要把sample K会话改成live,不根据官网营销句生成需求已验证结论。', '', '本地工作坊测试77/77通过;目标实例导入与看板呈现仍待实际执行、回读。']
+    report='\n'.join(lines)+'\n'
+    files={'scripts/workshop-first-live-import.mjs':(ROOT/'scripts/workshop-first-live-import.mjs').read_bytes(), 'scripts/first-live-batch.json':(SRC/'batch.json').read_bytes(), 'context-first-live.example.json':(json.dumps({'version':'ksf.first-live-import.v1','taskKey':KEY,'agentProfileKey':None},ensure_ascii=False,indent=2)+'\n').encode(), '给运维001-真实资料落地.txt':OPS.encode(), '给王刚001-真实资料入库.txt':WANG.encode(), '采集汇总.md':report.encode()}
+    for b in bundle['batches']:
+        for suffix in ['response.bin','html','txt','capture.json']:
+            p=SRC/(b['key']+'.'+suffix)
+            files['archives/'+p.name]=p.read_bytes()
+    sha=lambda b:hashlib.sha256(b).hexdigest()
+    files['FILES.sha256']=''.join(sha(b)+'  '+n+'\n' for n,b in files.items()).encode()
+    DEST.mkdir(parents=True,exist_ok=True)
+    target=DEST/'ksf-first-live-sources-20261102.zip'
+    with zipfile.ZipFile(target,'w',compression=zipfile.ZIP_DEFLATED) as z:
+        for n,b in files.items():z.writestr(n,b)
+    with zipfile.ZipFile(target) as z:
+        assert z.testzip() is None
+        for line in z.read('FILES.sha256').decode().splitlines():
+            expected,n=line.split('  ',1)
+            assert sha(z.read(n))==expected
+        assert sha(z.read('scripts/first-live-batch.json'))=='0118c861ae9a7da5e4d361497050200a2e4e595c7a2d76c733a22970cd179326'
+    for n in ['给运维001-真实资料落地.txt','给王刚001-真实资料入库.txt','采集汇总.md']:(DEST/n).write_bytes(files[n])
+    (ROOT/'docs/first-live-sources-20261102.md').write_text(report,encoding='utf-8')
+    receipt={'file':target.name,'bytes':target.stat().st_size,'sha256':sha(target.read_bytes()),'manifestChecked':str(len(files)-1)+'/'+str(len(files)-1),'executorSha256':sha(files['scripts/workshop-first-live-import.mjs']),'batchSha256':sha(files['scripts/first-live-batch.json']),'sourcePages':2,'productFlavorEntries':9,'databaseWrites':0,'targetExecuted':False}
+    (DEST/'ksf-first-live-sources-20261102.receipt.json').write_text(json.dumps(receipt,ensure_ascii=False,indent=2),encoding='utf-8')
+    print(json.dumps(receipt,ensure_ascii=False,indent=2))
+
+if __name__=='__main__':main()

+ 98 - 0
scripts/package-live-S1-v2.py

@@ -0,0 +1,98 @@
+"""Prepare the next report input and handoff files; never writes remote data."""
+from pathlib import Path
+import hashlib
+import json
+import zipfile
+
+ROOT = Path(__file__).resolve().parents[1]
+WORK = ROOT / 'work'
+DELIVERY = Path('F:/文档/工作坊当前资料')
+
+def digest(value):
+    return hashlib.sha256(value).hexdigest()
+
+def main():
+    template_path = WORK / 'first-live-S1-v2-template.json'
+    task = json.loads(template_path.read_text(encoding='utf-8'))
+    # Store the full report body in rawText as well as the structured chapters.
+    paragraphs = [task['title'], *task['report']['executive']]
+    for chapter in task['report']['chapters']:
+        paragraphs.append(chapter['title'])
+        for section in chapter['sections']:
+            paragraphs.append(section['title'])
+            if section['kind'] == 'para':
+                paragraphs.append(section['text'])
+            elif section['kind'] == 'table':
+                paragraphs.append(' | '.join(section['data']['cols']))
+                paragraphs.extend(' | '.join(row) for row in section['data']['rows'])
+            else:
+                raise ValueError('unsupported section')
+    for entry in task['report']['evidence'].values():
+        paragraphs.append(entry['claim'])
+        paragraphs.extend(q['text'] + '\n' + q['ref'] for q in entry['quotes'])
+    task['rawText'] = '\n\n'.join(paragraphs)
+    template_path.write_text(json.dumps(task, ensure_ascii=False, indent=2)+'\n', encoding='utf-8')
+
+    operations = '''运维001:准备S1小结第2版,业务仍由王刚001在真实消息任务里保存。
+
+先核FILES.sha256四项。将S1-save-v2.template.json安装到:
+/opt/data/ksf-executor-20261102/first-live-S1/S1-save-v2.template.json
+root:root 0644、hermes可读。保留原S1-save.template.json及所有原回执。新模板sessionId=null,不能直接提交。
+
+现有会话btrFqkz0yw,原报告cOm6c8gg7z,第1版。原taskKey=KSF-真实资料-20261102-01:S1,新version=2、requestId=KSF-真实资料-20261102-01:S1:save:v2。核对当前发送者和Agent与原会话归属一致,并检查服务端本系列第2版、该requestId历史、在途任务及本地回执;如已有任何记录先回读,不重放、不换键。Codex打包前已只读核对,不代表以后不会发生并发变化。
+
+业务输入由hermes生成/0600;既有/opt/data/ksf-workshop-private/S1-live-01为hermes/0700,继续留存,不清空。新文件S1-live-save-v2.json、S1-v2-save.stdout.json、S1-v2-save.stderr.txt、S1-v2-read.json及读取回执均不得覆盖已有文件。不得递归chown数据卷。
+
+无需重启、无需改Skill或Function,不重导入产品/引文,不改原报告,不代跑业务。交王刚001按另一份说明执行,回执给Codex后再做页面验收。本次新增报告来源记录属于保存小结的正常写入,不是再次采集或重复登记原网页。
+'''
+    wang = '''王刚001:继续真实流程测试,本轮只保存S1小结第2版,不再导资料。
+
+1. 在本条真实企微消息调用无参数工具ksfregistercurrent_employee。确认当前Agent和员工与现有live会话btrFqkz0yw的归属一致,并精确回读原报告cOm6c8gg7z(version=1)。不创建新会话,不用旧身份JSON冒充本条消息上下文。
+
+2. 等维护人确认模板安装和服务端/本地第2版历史、requestId和在途检查完成。若已有第2版或结果不明的执行回执,先回读并报告,不再提交。模板路径:
+/opt/data/ksf-executor-20261102/first-live-S1/S1-save-v2.template.json
+固定taskKey=KSF-真实资料-20261102-01:S1、version=2、requestId=KSF-真实资料-20261102-01:S1:save:v2。
+
+3. 以hermes(10000)、HOME=/opt/data运行。在内存读模板,仅将sessionId从null填成btrFqkz0yw;其余字段逐项保持一致。用独占创建方式(已存在则停)写/opt/data/ksf-workshop-private/S1-live-save-v2.json,0600/hermes。不打印完整输入;模板没有账号、会话键或凭据。
+
+4. 使用已安装入口执行一次(--agent-profile填本条登记回执返回且核实的当前Agent完整键):
+node /opt/data/skills/ksf-workshop-runtime/scripts/ksf-secure.mjs workshop-save --agent-profile <当前agentProfileKey> --input /opt/data/ksf-workshop-private/S1-live-save-v2.json --request-id KSF-真实资料-20261102-01:S1:save:v2
+用独占新建的0600文件在/opt/data/ksf-workshop-private/S1-live-01/留存stdout、stderr和退出码(S1-v2-save前缀)。先确认外层ok及entryResult.ok,再取entryResult.result中sessionId/artifactId/sourceId。失败、超时或结果不确定立即停止,保留原requestId与所有编号,不自动重试、不换键、不删记录。错误不等于零写入。
+
+5. 成功后精确回读新artifact。只读输入为{"className":"KfArtifact","objectId":"实际新artifactId"},独占写到0600的/opt/data/ksf-workshop-private/S1-live-01/S1-v2-read.json。
+node /opt/data/skills/ksf-workshop-runtime/scripts/ksf-secure.mjs workshop-read --input /opt/data/ksf-workshop-private/S1-live-01/S1-v2-read.json --request-id KSF-真实资料-20261102-01:S1:read:v2
+核对version=2、S1、live、dashboardState=workshop、sourceIds含BgUB6dnxN1/SFiKA0D7n9/McdwhD7KQq;五段逐字引文、六条待补问题和正文与模板相符。service会在摘要前加课堂声明及generatedAt,按服务合同核对。读不到私有meta不自行补造,可交Codex独立核对。
+
+6. 回报sessionId、artifactId、生成正文sourceId、reportUrl、version、stageCode、dataMode、boardVisible、formalReportPublished、employeeConfirmed、两层invocationId及回读是否通过。不重跑K轮/查重/原导入,不新建分类、产品、机会卡或项目。旧报告保留,新版得到新链接。本轮没开浏览器就报browserVerified=false;页面验收由Codex后续独立执行。
+
+内容口径:统一9个口味不等于9个SKU;康师傅是1条产品线、同一篇2022年媒体转载品牌通告中的3条引文,不能按条数与统一比较。现有材料不能证明当前在售或消费者偏好,滤水口不是新提出的创新方向,干面荟尚未建立产品记录。S1是课堂资料小结,不是正式市场结论。
+'''
+    members = {
+        'S1-save-v2.template.json': template_path.read_bytes(),
+        'S1小结第2版-审阅稿.md': (WORK/'first-live-S1-v2-review.md').read_bytes(),
+        '给运维001-S1第2版准备.txt': operations.encode('utf-8'),
+        '给王刚001-S1第2版保存.txt': wang.encode('utf-8'),
+    }
+    manifest = ''.join(digest(body)+'  '+name+'\n' for name,body in members.items()).encode('utf-8')
+    members['FILES.sha256'] = manifest
+    DELIVERY.mkdir(parents=True,exist_ok=True)
+    target = DELIVERY/'ksf-live-S1-v2-20261102.zip'
+    with zipfile.ZipFile(target,'w',compression=zipfile.ZIP_DEFLATED) as archive:
+        for name,body in members.items():
+            archive.writestr(name,body)
+    with zipfile.ZipFile(target) as archive:
+        assert archive.testzip() is None
+        for line in archive.read('FILES.sha256').decode('utf-8').splitlines():
+            expected,name = line.split('  ',1)
+            assert digest(archive.read(name)) == expected
+    (DELIVERY/'05-发给王刚001-S1第2版保存.txt').write_text(wang,encoding='utf-8')
+    (DELIVERY/'06-发给运维001-S1第2版准备.txt').write_text(operations,encoding='utf-8')
+    (DELIVERY/'S1小结第2版-审阅稿.md').write_bytes(members['S1小结第2版-审阅稿.md'])
+    receipt = {'file':str(target),'bytes':target.stat().st_size,'sha256':digest(target.read_bytes()),
+        'templateSha256':digest(template_path.read_bytes()),'manifestChecked':'4/4',
+        'taskKey':task['taskKey'],'version':task['version'],'remoteWrites':0,'targetSaved':False}
+    (WORK/'first-live-S1-v2-package-receipt.json').write_text(json.dumps(receipt,ensure_ascii=False,indent=2)+'\n',encoding='utf-8')
+    print(json.dumps(receipt,ensure_ascii=False,indent=2))
+
+if __name__ == '__main__':
+    main()

+ 107 - 0
scripts/package-live-S3-v2.py

@@ -0,0 +1,107 @@
+"""Package the unsaved S3 v2 display fix; never call business services."""
+from pathlib import Path
+import copy
+import hashlib
+import json
+import zipfile
+
+ROOT = Path(__file__).resolve().parents[1]
+DOCS = Path('F:/文档/工作坊当前资料')
+STAGE = ROOT / 'work/first-live-S3-v2-package'
+DELIVERY = DOCS / 'ksf-live-S3-v2-20261103.zip'
+RECEIPT = ROOT / 'work/first-live-S3-v2-package-receipt.json'
+
+def sha(data):
+    return hashlib.sha256(data).hexdigest()
+
+def as_json(value):
+    return (json.dumps(value, ensure_ascii=False, indent=2) + '\n').encode('utf-8')
+
+def write_new(path, data):
+    with path.open('xb') as output:
+        output.write(data)
+
+assert not STAGE.exists() and not DELIVERY.exists() and not RECEIPT.exists()
+v1_bytes = (ROOT / 'work/first-live-S3-template.json').read_bytes()
+v2_bytes = (ROOT / 'work/first-live-S3-v2-template.json').read_bytes()
+v1, v2 = json.loads(v1_bytes), json.loads(v2_bytes)
+assert sha(v1_bytes) == '987024e3a1d37e9ef17ee6bfdced24947e76d67c326027d6f92dddebb95d5478'
+assert sha(v2_bytes) == '41c32974519799103709fb124115ecd79a1ed2ef1b3b343c129e23cb2be9ca49'
+assert len(v2_bytes) == 21973 and v2['sessionId'] is None and v2['version'] == 2
+expected = copy.deepcopy(v1)
+expected['version'] = 2
+expected['report']['chapters'][2]['sections'].append({
+    'kind': 'table', 'title': '待补问题',
+    'data': {'cols': ['序号', '待核实问题'],
+             'rows': [[str(i + 1), q] for i, q in enumerate(v1['pendingQuestions'])]}
+})
+assert v2 == expected, 'Only version and pending-question display table may change'
+assert len(v2['rawText']) == 2554 and len(v2['pendingQuestions']) == 8
+assert len(v2['sourceIds']) == len(v2['report']['evidence']) == 4
+assert 'opportunity' not in v2 and 'projectId' not in v2
+preview = json.loads((ROOT / 'work/live-S3-v2-template-preview-20261103-02.json').read_bytes())
+assert preview['ok'] and preview['previewOnly'] and preview['templatePreviewVerified']
+assert preview['savedArtifactId'] is None and preview['businessWrites'] == 0
+assert preview['browserVerified'] is False
+assert len(preview['checks']) == 114 and all(x['passed'] for x in preview['checks'])
+assert preview['evidenceDrawersChecked'] == 8 and preview['errors'] == []
+
+preview_receipt = {key: preview[key] for key in [
+    'ok', 'operation', 'at', 'browserVerified', 'templatePreviewVerified', 'previewOnly',
+    'savedArtifactId', 'scope', 'readOnly', 'businessWrites', 'viewports',
+    'evidenceDrawersChecked', 'quoteComparison', 'checks', 'errors'
+]}
+preview_receipt['templateSha256'] = sha(v2_bytes)
+preview_receipt['note'] = '未保存模板的隔离浏览器内存预览;不是第2版生产页面验收。原始截图与完整记录由Codex本地保留。'
+payload = {
+    'S3-save-v2.template.json': v2_bytes,
+    '34-S3第1版独立验收与字段说明.md': (DOCS / '34-S3第1版独立验收与字段说明.md').read_bytes(),
+    '36-发给王刚001-S3第2版保存.txt': (DOCS / '36-发给王刚001-S3第2版保存.txt').read_bytes(),
+    'REPORT-PREVIEW.txt': ('S3第2版展示修复预览(未保存)\n'
+                           '只补页面待补问题表;以下rawText与第1版完全相同。\n\n' + v2['rawText']).encode('utf-8'),
+    'TEMPLATE-PREVIEW-VERIFY.json': as_json(preview_receipt),
+}
+manifest = {
+    'package': 'first-live-S3-v2-20261103',
+    'purpose': 'Unsaved S3 v2 template: display all eight existing pending questions',
+    'sessionIdInTemplate': None, 'taskKey': v2['taskKey'], 'version': 2,
+    'stageCode': 'S3', 'artifactType': v2['artifactType'],
+    'priorArtifactId': 'oiNxX7o27j',
+    'priorTemplateSha256': sha(v1_bytes), 'templateSha256': sha(v2_bytes),
+    'changedPaths': ['version', 'report.chapters[2].sections[3] (append table)'],
+    'rawTextUnchanged': True, 'sourceIds': v2['sourceIds'],
+    'pendingQuestionCount': 8, 'pendingQuestionsInDisplayTable': 8,
+    'evidenceKeys': list(v2['report']['evidence']),
+    'opportunityObjectPresent': False, 'projectIdPresent': False,
+    'templatePreviewVerified': True, 'browserVerified': False, 'businessWrites': 0,
+    'files': {name: {'bytes': len(data), 'sha256': sha(data)} for name, data in payload.items()},
+}
+payload['MANIFEST.json'] = as_json(manifest)
+checksums = ''.join(sha(payload[name]) + '  ' + name + '\n' for name in sorted(payload))
+payload['FILES.sha256'] = checksums.encode('utf-8')
+assert len(payload) == 7
+STAGE.mkdir()
+for name, data in payload.items():
+    write_new(STAGE / name, data)
+with zipfile.ZipFile(DELIVERY, 'x', compression=zipfile.ZIP_DEFLATED, compresslevel=9) as package:
+    for name in sorted(payload):
+        package.write(STAGE / name, arcname=name)
+with zipfile.ZipFile(DELIVERY) as package:
+    assert sorted(package.namelist()) == sorted(payload)
+    for name, data in payload.items():
+        assert package.read(name) == data
+        assert not name.startswith(('/', '\\')) and '..' not in Path(name).parts
+    assert package.testzip() is None
+    for line in checksums.splitlines():
+        expected_sha, name = line.split('  ', 1)
+        assert sha(package.read(name)) == expected_sha
+result = {
+    'ok': True, 'operation': 'package-unsaved-S3-v2-display-fix',
+    'packagePath': str(DELIVERY), 'packageBytes': DELIVERY.stat().st_size,
+    'packageSha256': sha(DELIVERY.read_bytes()), 'entries': len(payload), 'fileChecks': '6/6',
+    'templateBytes': len(v2_bytes), 'templateSha256': sha(v2_bytes),
+    'templatePreviewVerified': True, 'browserVerified': False,
+    'businessWrites': 0,
+}
+write_new(RECEIPT, as_json(result))
+print(json.dumps(result, ensure_ascii=True, indent=2))

+ 184 - 0
scripts/package-new-workshop-agent.py

@@ -0,0 +1,184 @@
+"""Package the new workshop Agent handoff documents; perform no deployment."""
+from pathlib import Path
+import hashlib
+import json
+import os
+import re
+import tarfile
+import zipfile
+
+ROOT = Path(__file__).resolve().parents[1]
+DOCS = Path(os.environ.get('KSF_NEW_AGENT_HANDOFF_DIR', 'F:/文档/新数字人交接'))
+DEST = ROOT / 'work/delivery-by-recipient-20261102'
+DOCUMENT_NAMES = [
+    'README.md',
+    '00-你需要手动做什么.txt',
+    '01-发给运维001的接入说明.md',
+    '02-新数字人角色说明.txt',
+    '03-新数字人工作手册.md',
+    '04-现有成果与接手范围.md',
+    '05-验收与回报清单.md',
+    '06-安装材料索引.json',
+]
+
+
+def digest(data):
+    return hashlib.sha256(data).hexdigest()
+
+
+def require(condition, message):
+    if not condition:
+        raise ValueError(message)
+
+
+def manifest_entries(data):
+    result = {}
+    for line in data.decode('utf-8-sig').splitlines():
+        if not line.strip():
+            continue
+        match = re.fullmatch(r'([a-f0-9]{64})  (.+)', line)
+        require(match is not None, 'Malformed manifest')
+        sha, name = match.groups()
+        require(name not in result, 'Duplicate manifest entry')
+        require(not name.startswith('/') and '..' not in name.split('/'), 'Unsafe member')
+        result[name] = sha
+    return result
+
+
+def read_package(path, expected_sha, expected_bytes, manifest_name, expected_count, prefix=''):
+    data = path.read_bytes()
+    require(len(data) == expected_bytes and digest(data) == expected_sha, f'Package mismatch: {path.name}')
+    if path.suffix == '.tgz':
+        with tarfile.open(path) as archive:
+            members = archive.getmembers()
+            require(all(not m.issym() and not m.islnk() for m in members), 'Unexpected archive link')
+            files = {m.name: archive.extractfile(m).read() for m in members if m.isfile()}
+    else:
+        with zipfile.ZipFile(path) as archive:
+            require(archive.testzip() is None, 'Corrupt input ZIP')
+            require(len(archive.namelist()) == len(set(archive.namelist())), 'Duplicate ZIP members')
+            files = {name: archive.read(name) for name in archive.namelist() if not name.endswith('/')}
+    entries = manifest_entries(files[manifest_name])
+    require(len(entries) == expected_count, 'Unexpected manifest size')
+    for name, sha in entries.items():
+        require(digest(files[prefix + name]) == sha, f'Member mismatch: {name}')
+    return files, {
+        'filename': path.name,
+        'bytes': len(data),
+        'sha256': digest(data),
+        'knownLocalPath': path.as_posix(),
+        'manifestPath': manifest_name,
+        'manifestVerified': f'{len(entries)}/{expected_count}',
+        'includedInHandoffZip': False,
+    }
+
+
+def main():
+    v4, v4_info = read_package(
+        Path('D:/下载/WXWork/1688857450794210/Cache/File/2026-11/ksf-adapter-patch-v4-20261102.tgz'),
+        'fc1888c99160e8af28a69355e78c91d1f4f8b3f612c1cc0f0fb0d1f4f675f0a5', 65842,
+        'adapter-patch-v4/MANIFEST.sha256', 21, 'adapter-patch-v4/',
+    )
+    dedup, dedup_info = read_package(
+        DEST / 'ksf-workshop-dedup-20261102.zip',
+        '96fc5c10557b7601b63767e293bcfc33fcea1058f21961949348c105cd75afde', 339939,
+        'FILES.sha256', 34,
+    )
+    hotfix, hotfix_info = read_package(
+        DEST / 'ksf-wecom-tool-result-hotfix-20261102.zip',
+        'ea1cd7e2663b8d67f0ca12381c131bddd4edd51e45518fb55fb04c4d836538e8', 39426,
+        'FILES.sha256', 14,
+    )
+    skill_prefix = 'skills/ksf-workshop-runtime/'
+    skill_entries = manifest_entries(dedup[skill_prefix + 'FILES.sha256'])
+    require(len(skill_entries) == 10, 'Skill manifest count changed')
+    for name, sha in skill_entries.items():
+        require(digest(dedup[skill_prefix + name]) == sha, 'Skill manifest mismatch')
+
+    final_files = []
+    for package_info, files, member, target in [
+        (v4_info, v4, 'adapter-patch-v4/aibot_identity.py', '/opt/hermes/plugins/platforms/wecom/aibot_identity.py'),
+        (v4_info, v4, 'adapter-patch-v4/patches/adapter.py.patched', '/opt/hermes/plugins/platforms/wecom/adapter.py'),
+        (v4_info, v4, 'adapter-patch-v4/plugin/wecom-identity/plugin.yaml', '/opt/data/plugins/wecom-identity/plugin.yaml'),
+        (v4_info, v4, 'adapter-patch-v4/plugin/wecom-identity/__init__.py', '/opt/data/plugins/wecom-identity/__init__.py'),
+        (hotfix_info, hotfix, 'integrations/wecom-identity/identity_bridge.py', '/opt/hermes/plugins/platforms/wecom/identity_bridge.py'),
+        (hotfix_info, hotfix, 'integrations/wecom-identity/workshop_registration.py', '/opt/hermes/plugins/platforms/wecom/workshop_registration.py'),
+    ]:
+        final_files.append({'package': package_info['filename'], 'member': member,
+                            'targetForVerifiedLayout': target, 'sha256': digest(files[member])})
+
+    index = {
+        'version': 'ksf.new-workshop-agent-handoff.v1',
+        'preparedDate': '2026-11-02',
+        'purpose': '新实例接入材料索引;只核对原件与清单,不是首次开通器或部署脚本',
+        'suggestedNameOnly': '康师傅工作坊助手001',
+        'newAgentCreated': False,
+        'newAgentRegistered': False,
+        'newTargetChanged': False,
+        'configuredNameTriggersRegistration': False,
+        'platform': 'Hermes + WeCom aibot WebSocket',
+        'verifiedLayout': {'hermesVersion': '0.21.3', 'gatewayUser': 'hermes', 'uid': 10000,
+                           'hermesHome': '/opt/data', 'requiresNewTargetVerification': True},
+        'packages': [v4_info, dedup_info, hotfix_info],
+        'adapterRequiredBaselineSha256': digest(v4['adapter-patch-v4/baseline/adapter.py']),
+        'finalPlatformFiles': final_files,
+        'skill': {'package': dedup_info['filename'], 'prefix': skill_prefix,
+                  'files': skill_entries, 'manifestVerified': '10/10',
+                  'metadataManifestSha256': digest(dedup[skill_prefix + 'FILES.sha256'])},
+        'toolNames': ['wecom_identity_current', 'ksf_register_current_employee'],
+        'contractMarkers': ['ksf.agent-self.v1', 'ksf.wecom-aibot-sender.v1', 'ksf.ingest-dedup.v1'],
+        'operatorNotes': [
+            '原安装包不包含在本交接ZIP;运维可按名称和完整指纹复用已收到的原件。',
+            '旧收据的未部署状态仅指打包时;旧测试实例已验,新实例仍待验。',
+            '现有后台先回读,不运行旧身份部署覆盖当前查重合同。',
+            'install-workshop-skill.mjs是升级器;新实例运行时/独立凭据须经受控首次开通。',
+            '新bot账号与最终名称需一次受控Agent建档及registration.json绑定;名称本身不触发登记。',
+            '不复制旧私钥、信封、credential-binding、Agent/员工状态、instance或context-K。',
+            '全流程与查重脚本含旧固定任务键;新实例不得原样重放。',
+            '本次核验是档案和指纹核验;不代表新实例功能验收已通过。',
+        ],
+    }
+    (DOCS / '06-安装材料索引.json').write_text(json.dumps(index, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
+
+    files = {name: (DOCS / name).read_bytes() for name in DOCUMENT_NAMES}
+    require(all(data for data in files.values()), 'Empty handoff document')
+    for name in ['00-你需要手动做什么.txt', '01-发给运维001的接入说明.md',
+                 '02-新数字人角色说明.txt', '03-新数字人工作手册.md']:
+        text = files[name].decode('utf-8')
+        require('ksf_register_current_employee' in text, 'Missing real tool name')
+        require('ksfregistercurrent' not in text, 'Incorrect tool alias')
+    for text in files.values():
+        require(b'-----BEGIN PRIVATE KEY-----' not in text, 'Unexpected private key')
+    files['FILES.sha256'] = ''.join(f'{digest(data)}  {name}\n' for name, data in files.items()).encode('utf-8')
+    (DOCS / 'FILES.sha256').write_bytes(files['FILES.sha256'])
+    archive_path = DOCS / 'ksf-new-workshop-agent-handoff-20261102.zip'
+    with zipfile.ZipFile(archive_path, 'w', compression=zipfile.ZIP_DEFLATED) as archive:
+        for name, data in files.items():
+            info = zipfile.ZipInfo(name, date_time=(2026, 11, 2, 0, 0, 0))
+            info.compress_type = zipfile.ZIP_DEFLATED
+            info.external_attr = 0o100644 << 16
+            archive.writestr(info, data)
+    with zipfile.ZipFile(archive_path) as archive:
+        require(archive.testzip() is None, 'Corrupt output ZIP')
+        require(set(archive.namelist()) == set(files), 'Unexpected output member')
+        entries = manifest_entries(archive.read('FILES.sha256'))
+        require(len(entries) == 8, 'Unexpected handoff document count')
+        for name, sha in entries.items():
+            require(digest(archive.read(name)) == sha, 'Handoff manifest mismatch')
+
+    receipt = {
+        'file': archive_path.name, 'bytes': archive_path.stat().st_size,
+        'sha256': digest(archive_path.read_bytes()), 'archiveIntegrity': True,
+        'manifestVerified': '8/8', 'zipEntries': 9,
+        'originalPackageManifestsVerified': ['21/21', '34/34', '14/14'],
+        'skillManifestVerified': '10/10', 'contents': 'handoff-documents-only',
+        'includesInstallationArchives': False, 'includesCredentials': False,
+        'newAgentCreated': False, 'newAgentRegistered': False, 'newTargetChanged': False,
+    }
+    (DOCS / 'ksf-new-workshop-agent-handoff-20261102.receipt.json').write_text(
+        json.dumps(receipt, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
+    print(json.dumps(receipt, ensure_ascii=False, indent=2))
+
+
+if __name__ == '__main__':
+    main()

+ 134 - 0
scripts/package-query-skills.py

@@ -0,0 +1,134 @@
+"""Prepare/finalize the query-rule handoff without copying private databases."""
+import argparse
+import hashlib
+import json
+from pathlib import Path
+import shutil
+import zipfile
+
+ROOT = Path(__file__).resolve().parents[1]
+DIST = Path('F:/文档/康师傅skill')
+WORK = ROOT / 'work/agent-query-20261106'
+OUT = ROOT / 'artifacts/数据库精准检索规则-20261106'
+NAME = 'ksf-agent-query-rules-20261106-r1'
+RELEASE = OUT / NAME
+SKILLS = ('ksf-workshop-runtime', 'ksf-voc-library')
+
+
+def sha(path):
+    with path.open('rb') as stream:
+        return hashlib.file_digest(stream, 'sha256').hexdigest()
+
+
+def write(path, data):
+    path.parent.mkdir(parents=True, exist_ok=True)
+    path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
+
+
+def files(folder):
+    return {p.relative_to(folder).as_posix(): sha(p) for p in sorted(folder.rglob('*')) if p.is_file() and '__pycache__' not in p.parts}
+
+
+def manifest(folder):
+    entries = {name: value for name, value in files(folder).items() if name != 'FILES.sha256'}
+    (folder / 'FILES.sha256').write_text(''.join(value + '  ' + name + '\n' for name, value in entries.items()), encoding='utf-8', newline='\n')
+
+
+def prepare():
+    if RELEASE.exists():
+        raise ValueError('RELEASE_ALREADY_PREPARED')
+    for name in ('query_voc.py', 'voc_db.py', 'retrieval.py', 'schema.sql', 'taxonomy.json'):
+        shutil.copyfile(ROOT / 'server/voc-library' / name, ROOT / 'skills/ksf-voc-library/scripts' / name)
+    for name in SKILLS:
+        manifest(ROOT / 'skills' / name)
+        shutil.copytree(ROOT / 'skills' / name, RELEASE / 'skills' / name)
+    baselines = {name: [files(WORK / 'baseline-skills' / name)] for name in SKILLS}
+    with zipfile.ZipFile(DIST / '历史归档/20261104-先查库/ksf-workshop-runtime-20261104.zip') as archive:
+        baselines['ksf-workshop-runtime'].append({name: hashlib.sha256(archive.read(name)).hexdigest() for name in archive.namelist() if not name.endswith('/')})
+    write(RELEASE / 'baselines.json', baselines)
+    shutil.copyfile(ROOT / 'scripts/install-query-skills.py', RELEASE / 'install-query-skills.py')
+    for source in (ROOT / 'docs/voc-agent-query-20261106').glob('*.md'):
+        target = RELEASE / 'docs' / source.name
+        target.parent.mkdir(parents=True, exist_ok=True)
+        target.write_text(source.read_text(encoding='utf-8').replace('../../skills/', '../skills/'), encoding='utf-8')
+    scope = dict(platform='jd', brand='ksf', category='banmian')
+    examples = {
+        '01-jd-ksf-banmian-products': dict(entities=['products'], filters=scope, limit=20),
+        '02-jd-ksf-banmian-reviews': dict(entities=['reviews'], filters=scope, quality='analysis', limit=20),
+        '03-jd-ksf-banmian-review-audit': dict(entities=['reviews'], filters=scope, quality='audit', groupBy=['isDefault', 'identityState', 'categoryState'], limit=100),
+        '04-brand-products': dict(entities=['products'], filters=dict(brand='ksf'), limit=20),
+        '05-nutrition-documents': dict(entities=['documents', 'clauses'], text=dict(any=['营养标签', '营养成分表']), limit=20),
+        '06-jd-ksf-banmian-prices-specs': dict(entities=['prices', 'specs'], filters=scope, limit=20),
+        '07-jd-ksf-banmian-portion': dict(entities=['reviews'], filters=scope, text=dict(concepts=['portion_size']), limit=20),
+        '08-jd-ksf-banmian-review-statistics': dict(entities=['reviews'], filters=scope, groupBy=['platform', 'brand', 'category'], limit=100),
+        '09-jd-ksf-banmian-coverage': dict(entities=['coverage'], filters=scope, limit=20),
+    }
+    for name, data in examples.items():
+        write(RELEASE / 'examples' / (name + '.json'), data)
+    write(RELEASE / 'release.json', dict(version=NAME, agentRulesVersion='ksf.agent-data-rules.20261106.v1',
+        schema='ksf.voc-library.v2', retrievalContract='ksf.voc-retrieval.v2', databaseIncluded=False,
+        databaseMigrationRequired=False, productionInstalled=False, realMessageVerified=False,
+        workshopRuntimeScriptsChanged=False, researchScriptsChanged=['voc_db.py', 'retrieval.py']))
+    print(json.dumps(dict(prepared=str(RELEASE), next='Run verify-query-skills.py before finalize'), ensure_ascii=False))
+
+
+def finalize():
+    validation = json.loads((WORK / 'validation.json').read_text(encoding='utf-8'))
+    if not validation['ok'] or (OUT / (NAME + '.zip')).exists():
+        raise ValueError('VALIDATION_REQUIRED_OR_FROZEN_ZIP_EXISTS')
+    for name in SKILLS:
+        if files(ROOT / 'skills' / name) != files(RELEASE / 'skills' / name):
+            raise ValueError('SKILL_CHANGED_AFTER_PREPARATION')
+    shutil.copyfile(WORK / 'validation.json', RELEASE / 'local-validation.json')
+    manifest(RELEASE)
+    archive = OUT / (NAME + '.zip')
+    with zipfile.ZipFile(archive, 'x', zipfile.ZIP_DEFLATED) as z:
+        for path in sorted(RELEASE.rglob('*')):
+            if path.is_file():
+                z.write(path, path.relative_to(RELEASE).as_posix())
+    receipt = dict(release=NAME, filename=archive.name, bytes=archive.stat().st_size, sha256=sha(archive),
+        files=len(files(RELEASE)), localValidated=True, productionInstalled=False, realMessageVerified=False)
+    write(OUT / 'delivery-receipt.json', receipt)
+    for name in SKILLS:
+        source, target = ROOT / 'skills' / name, DIST / name
+        for path in source.rglob('*'):
+            if path.is_file():
+                out = target / path.relative_to(source)
+                out.parent.mkdir(parents=True, exist_ok=True)
+                shutil.copyfile(path, out)
+    shutil.copyfile(archive, DIST / archive.name)
+    write(DIST / '版本与校验.json', dict(preparedDate='2026-11-06', release=NAME,
+        skills={name: dict(files=files(DIST / name), manifestSha256=sha(DIST / name / 'FILES.sha256')) for name in SKILLS},
+        zip=receipt, previousPreparedVersion='历史归档/20261105-r2-查询规则更新前.zip',
+        databaseIncluded=False, productionInstalled=False, realMessageValidated=False))
+    (DIST / 'README.md').write_text('''# 当前Skill分发 · 2026-11-06
+
+当前安装入口:ksf-agent-query-rules-20261106-r1.zip;ZIP、两份展开Skill与版本与校验.json一致。
+
+- ksf-workshop-runtime:工作坊技能,研究底料先查研究库、报告和项目查业务库;仅说明和清单升级,身份、昵称、上传与运行脚本沿用原版。
+- ksf-voc-library:只读研究查询,精确字段AND、商品评价关联、来源回读、统计口径及同一generation校验;支持与看板使用同一registry/current.json。
+- 不含数据库,沿用已安装v2研究库。缺库时需先用原完整r2资料包建立授权库;不能只复制Skill就声称能查到资料。
+
+本机准备和验证完成,未在王刚001生产实例安装/加载/真实消息验收。请将当前完整ZIP与包内docs/发给运维001.md一起转运维;安装后将docs/发给王刚001.md交本人真实消息验证。历史ZIP不再作为当前升级入口,旧r2展开内容已归档,原ZIP未修改。
+
+新增同结构数据通过新代际更新,Agent新研究读取当前代际;新增字段/实体/分类定义才更新合同与Skill。没有新增采集工具,不调用付费上游。
+''', encoding='utf-8')
+    handoff = Path('F:/文档/新数字人交接')
+    (handoff / '10-精准查库规则增量-20261106.md').write_text('''# 精准查库规则增量 · 2026-11-06
+
+当前增量包:F:/文档/康师傅skill/ksf-agent-query-rules-20261106-r1.zip。
+
+原00—09、materials与旧ZIP保持历史快照;新增实例在完成原有身份/运行时开通和授权研究库后,使用此增量包对齐两份Skill。不要把旧materials直接当成当前查询规则,或复用其他员工身份/会话/凭据。
+
+包内含精确基线升级器、两份Skill、数据与来源合同、9个查询示例、运维与王刚001验收单及本地验证回执。不含数据库,无Schema迁移;同一研究任务固定generation,新增数据经同一current.json取新代际。
+
+目前仅本地准备和测试完成,生产安装、Agent实际加载、真实消息验收仍待运维与本人回报。
+''', encoding='utf-8')
+    print(json.dumps(receipt, ensure_ascii=False, indent=2))
+
+
+if __name__ == '__main__':
+    parser = argparse.ArgumentParser(description=__doc__)
+    parser.add_argument('phase', choices=['prepare', 'finalize'])
+    args = parser.parse_args()
+    prepare() if args.phase == 'prepare' else finalize()

+ 76 - 0
scripts/package-report-evidence-ui.py

@@ -0,0 +1,76 @@
+"""Build a fixed three-file site patch. No site or database writes."""
+from pathlib import Path
+import hashlib
+import json
+import zipfile
+
+ROOT=Path(__file__).resolve().parents[1]
+DELIVERY=Path('F:/文档/工作坊当前资料')
+FILES=['src/core/report.js','src/core/evidence/chain.js','src/layout/kf-app.js']
+def sha(body):
+    return hashlib.sha256(body).hexdigest()
+
+def main():
+    baseline=json.loads((ROOT/'work/report-evidence-before.json').read_text(encoding='utf-8'))
+    by_path={row['path']:row for row in baseline['files']}
+    manifest={
+        'version':'ksf.report-evidence.publish.v1',
+        'release':'ksf.report-evidence.20261102.v1',
+        'homepageSha256':by_path['index.html']['sha256'],
+        'baselineObservedAt':baseline['at'],
+        'files':[{'path':name,'sha256':sha((ROOT/name).read_bytes()),
+                  'priorNormalizedSha256':[by_path[name]['normalizedSha256']],'allowMissing':False} for name in FILES],
+    }
+    manifest_path=ROOT/'deploy/report-evidence-files-20261102.json'
+    manifest_path.write_text(json.dumps(manifest,ensure_ascii=False,indent=2)+'\n',encoding='utf-8')
+    instructions='''运维001:发布报告引文显示修复,只改三个前端文件,无业务数据写入。
+
+原因:S1第2版G8IrkkDG7X的正文、五段引文和六条待补问题已独立回读通过;但报告是表格/段落结构,旧页面未生成引用入口。修复补齐所有report.evidence的入口,并保留引用的sourceId,避免已入库原文误标为“未落入知识库”。不修改报告内容、不补造消费者结论。
+
+包:ksf-report-evidence-ui-20261102.zip。先核FILES.sha256六项。发布器为独立新文件scripts/publish-report-evidence.mjs,旧九文件发布器不变。
+
+只允许三个文件:
+src/core/report.js
+src/core/evidence/chain.js
+src/layout/kf-app.js
+原首页SHA-256=dfeb6a6d0b63e8e1190f9308bf6619a5ca523a5a2f91af5f7ca5ad5fc9642db3。
+旧文件基线来自2026-11-02线上独立只读读取,并已与本地原件逐字核对;manifest记载完整旧/新指纹。任何未知版本或首页不符都停止,不改指纹放行。
+
+1. 解压到与站点目录互不嵌套的独立目录,核对6/6。由站点维护人使用此前核实的kangshifu.fmode.cn实际静态文件目录;不能用数字人容器/opt/data冒充站点目录,也不能根据域名猜目录。
+2. 在解包根目录以Node执行只读计划(把占位符替换为维护人已核实的绝对目录):
+node scripts/publish-report-evidence.mjs --target-dir <实际站点绝对目录>
+检查filesChecked=3、homepageModified=false,changes仅上述三文件。如果全部unchanged,直接回读,不重复发布。
+3. 计划通过,执行一次:
+node scripts/publish-report-evidence.mjs --target-dir <同一实际站点绝对目录> --apply
+发布器在站点目录外先备份三个旧文件,然后逐个原子替换并回读;同时保护首页与目标指纹。失败会尝试回退且不覆盖别人的并发改动,保留backup和journal,不循环重试。
+4. 回报计划/apply退出码、changes、三个文件回读hash、首页hash、备份路径、filesystemVerified。站点文件更新不等于域名已更新,CDN缓存如存在需由维护人按既有方式处理,并如实回报;由Codex再验正常URL的三个模块指纹和匿名浏览器。
+
+回退:从本次backup读取journal,在恢复每个旧文件前先确认目标仍是journal里本次afterSha256;不符就保留现场报告并发变化。仅恢复这三文件,不动index.html,不清空或重建站点目录。备份保留。
+
+边界:不跑完整deploy.sh、不改首页/样式/登录流程、不改Skill、Function或schema,不重启网关,不重新保存S1或导入资料。王刚001当前没有待执行的业务命令。
+
+Codex隔离浏览器已使用真实线上公开数据、仅替换上述三模块进行桌面和390px检查:五段引文均可点击查看、六条问题可见、旧报告保留、页面无横向溢出。该结果不代表生产站点已应用修复,线上browserVerified仍待本次发布后的独立验收。
+'''
+    payload={name:(ROOT/name).read_bytes() for name in FILES}
+    payload['scripts/publish-report-evidence.mjs']=(ROOT/'scripts/publish-report-evidence.mjs').read_bytes()
+    payload['deploy/report-evidence-files-20261102.json']=manifest_path.read_bytes()
+    payload['INSTALL.txt']=instructions.encode('utf-8')
+    payload['FILES.sha256']=''.join(sha(body)+'  '+name+'\n' for name,body in payload.items()).encode('utf-8')
+    DELIVERY.mkdir(parents=True,exist_ok=True)
+    target=DELIVERY/'ksf-report-evidence-ui-20261102.zip'
+    with zipfile.ZipFile(target,'w',compression=zipfile.ZIP_DEFLATED) as archive:
+        for name,body in payload.items():
+            archive.writestr(name,body)
+    with zipfile.ZipFile(target) as archive:
+        assert archive.testzip() is None
+        for line in archive.read('FILES.sha256').decode('utf-8').splitlines():
+            expected,name=line.split('  ',1)
+            assert sha(archive.read(name))==expected
+    (DELIVERY/'07-发给运维001-报告引文显示修复.txt').write_text(instructions,encoding='utf-8')
+    receipt={'file':str(target),'bytes':target.stat().st_size,'sha256':sha(target.read_bytes()),'manifestChecked':'6/6',
+             'frontendFiles':FILES,'sitePublished':False,'businessWrites':0,'homepageModified':False}
+    (ROOT/'work/report-evidence-ui-package-receipt.json').write_text(json.dumps(receipt,ensure_ascii=False,indent=2)+'\n',encoding='utf-8')
+    print(json.dumps(receipt,ensure_ascii=True,indent=2))
+
+if __name__=='__main__':
+    main()

+ 176 - 0
scripts/package-voc-database.py

@@ -0,0 +1,176 @@
+"""Prepare a database-only supplement for the already installed Nov 6 query skills."""
+import argparse
+import hashlib
+import importlib.util
+import json
+from pathlib import Path
+import shutil
+import sys
+import tempfile
+import zipfile
+
+ROOT = Path(__file__).resolve().parents[1]
+WORK = ROOT / 'work/voc-database-delivery-20261106'
+OUT = ROOT / 'artifacts/研究数据库补装-20261106'
+NAME = 'ksf-voc-database-20261106-r1'
+RELEASE = OUT / NAME
+DIST = Path('F:/文档/康师傅skill')
+SKILL = ROOT / 'skills/ksf-voc-library'
+EXAMPLES = ROOT / 'artifacts/数据库精准检索规则-20261106/ksf-agent-query-rules-20261106-r1/examples'
+
+
+def digest(path):
+    with Path(path).open('rb') as stream:
+        return hashlib.file_digest(stream, 'sha256').hexdigest()
+
+
+def write(path, data):
+    path.parent.mkdir(parents=True, exist_ok=True)
+    path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
+
+
+def manifest(folder):
+    entries = {p.relative_to(folder).as_posix(): digest(p) for p in sorted(folder.rglob('*')) if p.is_file() and p.name != 'FILES.sha256'}
+    (folder / 'FILES.sha256').write_text(''.join(sha + '  ' + name + '\n' for name, sha in entries.items()), encoding='utf-8', newline='\n')
+
+
+def load_module(name, path):
+    spec = importlib.util.spec_from_file_location(name, path)
+    module = importlib.util.module_from_spec(spec)
+    spec.loader.exec_module(module)
+    return module
+
+
+def prepare():
+    if RELEASE.exists():
+        raise ValueError('FROZEN_RELEASE_ALREADY_EXISTS')
+    sys.path.insert(0, str(ROOT / 'server/voc-library'))
+    import voc_db
+    registry = ROOT / 'work/library-preview/registry'
+    pointer = json.loads((registry / 'current.json').read_text(encoding='utf-8'))
+    source = voc_db.registry_database(registry)
+    if digest(source) != pointer['databaseSha256']:
+        raise ValueError('LOCAL_BOARD_POINTER_HASH_MISMATCH')
+    with voc_db.readonly(source) as con:
+        meta = dict(con.execute('SELECT key,value FROM meta'))
+        entity_counts = voc_db.table_counts(con)
+        counts = {**entity_counts, 'retrieval_documents': con.execute('SELECT count(*) FROM retrieval_documents').fetchone()[0]}
+    database = dict(schema=meta['schema'], generation=meta['generation'], bytes=source.stat().st_size, sha256=digest(source),
+        originalArchiveSha256=meta['archive_sha256'], entityCounts=entity_counts, counts=counts)
+    destination = RELEASE / 'library/library.sqlite'
+    destination.parent.mkdir(parents=True)
+    shutil.copyfile(source, destination)
+    if digest(destination) != pointer['databaseSha256']:
+        raise ValueError('COPIED_DATABASE_HASH_MISMATCH')
+    required_skill = digest(SKILL / 'FILES.sha256')
+    if required_skill != '4da410bb882dc73e277c740d13407918d9c9d5da2196ac9211f6ff7c009692fb':
+        raise ValueError('INSTALLED_QUERY_RELEASE_NO_LONGER_MATCHES')
+    write(RELEASE / 'DATABASE.json', dict(release=NAME, database=database,
+        requiredResearchSkillManifestSha256=required_skill, source='Codex local board current database snapshot',
+        matchesLocalBoardDatabaseBytes=True, storageMode='static-snapshot', automaticUpdatesConfigured=False,
+        productionInstalled=False, productionBoardAligned=False, realMessageVerified=False,
+        skillIncluded=False, skillInstallationRequired=False, defaultDatabasePath='/opt/data/ksf-voc-library/library.sqlite'))
+    for name in ('install-voc-database.py', 'verify-installed-voc-database.py'):
+        shutil.copyfile(ROOT / 'scripts' / name, RELEASE / name)
+    shutil.copytree(EXAMPLES, RELEASE / 'examples')
+    shutil.copytree(ROOT / 'docs/voc-database-delivery-20261106', RELEASE / 'docs')
+    manifest(RELEASE)
+    print(json.dumps(dict(prepared=str(RELEASE), database=database), ensure_ascii=False, indent=2))
+
+
+def validate():
+    WORK.mkdir(parents=True, exist_ok=True)
+    installer = load_module('seed_install', RELEASE / 'install-voc-database.py')
+    verifier = load_module('seed_verify', RELEASE / 'verify-installed-voc-database.py')
+    with tempfile.TemporaryDirectory(prefix='simulate-', dir=WORK) as temporary:
+        root = Path(temporary)
+        target = root / 'ksf-voc-library'
+        plan = installer.install(RELEASE, target)
+        if target.exists() or not plan['readOnly']:
+            raise ValueError('DRY_PLAN_WROTE_TARGET')
+        applied = installer.install(RELEASE, target, True)
+        landed = target / 'library.sqlite'
+        before = (digest(landed), landed.stat().st_mtime_ns)
+        repeated = installer.install(RELEASE, target, True)
+        if repeated['applied'] or (digest(landed), landed.stat().st_mtime_ns) != before:
+            raise ValueError('REPEAT_CHANGED_DATABASE')
+        verification = verifier.verify(RELEASE, SKILL, landed)
+        if (digest(landed), landed.stat().st_mtime_ns) != before:
+            raise ValueError('READBACK_CHANGED_DATABASE')
+        result = dict(ok=True, package=NAME, installPlan=plan, installSimulation=applied,
+            exactRepeatVerified=True, installedReadback=verification,
+            skillFilesChanged=False, productionInstalled=False, realMessageVerified=False)
+        write(WORK / 'local-validation.json', result)
+        print(json.dumps(dict(ok=True, generation=verification['generation'], scenarios=len(verification['scenarios']),
+            sourceChecks=len(verification['citations']), installedDefaultPathSimulated=True, exactRepeatVerified=True), ensure_ascii=False, indent=2))
+
+
+def finalize():
+    validation = json.loads((WORK / 'local-validation.json').read_text(encoding='utf-8'))
+    if not validation['ok']:
+        raise ValueError('VALIDATION_REQUIRED')
+    package_meta = json.loads((RELEASE / 'DATABASE.json').read_text(encoding='utf-8'))
+    if digest(RELEASE / 'library/library.sqlite') != validation['installedReadback']['databaseSha256'] or package_meta['database']['generation'] != validation['installedReadback']['generation']:
+        raise ValueError('DATABASE_CHANGED_AFTER_VALIDATION')
+    archive = OUT / (NAME + '.zip')
+    if archive.exists():
+        raise ValueError('ZIP_ALREADY_FROZEN')
+    shutil.copyfile(WORK / 'local-validation.json', RELEASE / 'local-validation.json')
+    manifest(RELEASE)
+    with zipfile.ZipFile(archive, 'x', zipfile.ZIP_DEFLATED) as z:
+        for path in sorted(RELEASE.rglob('*')):
+            if path.is_file():
+                z.write(path, path.relative_to(RELEASE).as_posix())
+    with zipfile.ZipFile(archive) as z:
+        entries = z.read('FILES.sha256').decode('utf-8').splitlines()
+        for line in entries:
+            expected, name = line.split('  ', 1)
+            if hashlib.sha256(z.read(name)).hexdigest() != expected:
+                raise ValueError('ZIP_READBACK_MISMATCH')
+    receipt = dict(release=NAME, filename=archive.name, bytes=archive.stat().st_size, sha256=digest(archive),
+        entries=len(entries)+1, rootManifestVerified=str(len(entries)) + '/' + str(len(entries)),
+        database=json.loads((RELEASE / 'DATABASE.json').read_text(encoding='utf-8'))['database'],
+        localValidated=True, databaseIncluded=True, skillIncluded=False, productionInstalled=False,
+        realMessageVerified=False, productionBoardAligned=False)
+    write(OUT / 'delivery-receipt.json', receipt)
+    shutil.copyfile(archive, DIST / archive.name)
+    shutil.copyfile(OUT / 'delivery-receipt.json', DIST / '研究数据库补包校验-20261106.json')
+    note = '''
+## 研究库补装 · 2026-11-06
+
+已收到运维与王刚001回报:本目录11月6日两份Skill已安装且实际加载;研究数据库此前未安装,查询停在DATABASE_NOT_INSTALLED。对此不需要重装或回退Skill。
+
+请只转交 **ksf-voc-database-20261106-r1.zip** 与其包内docs/发给运维001-补装研究数据库.md;校验见研究数据库补包校验-20261106.json。该包只含数据库本体、安装/验收工具和样例,不含Skill,装到既定默认路径,无须新凭据或改网关。
+
+补包取自Codex本地看板当前快照;尚未安装到远端,尚未对齐远端生产看板或配置后续自动同步。数据库装好再由王刚001继续原五步真实消息验收。原r2整包中的旧Skill安装器不要再执行。
+'''
+    readme = (DIST / 'README.md').read_text(encoding='utf-8')
+    readme = readme.replace('不含数据库,沿用已安装v2研究库。缺库时需先用原完整r2资料包建立授权库;不能只复制Skill就声称能查到资料。',
+        '查询规则包不含数据库。缺库时用本目录新增的数据库专用补包落地,不再执行原r2整包中的旧Skill安装器。')
+    readme = readme.replace('本机准备和验证完成,未在王刚001生产实例安装/加载/真实消息验收。请将当前完整ZIP与包内docs/发给运维001.md一起转运维;安装后将docs/发给王刚001.md交本人真实消息验证。',
+        '已收到运维001报告两份Skill安装完成,王刚001确认真实加载;数据库此前未安装,真实数据查询尚未验收。请按下方数据库补装说明继续,不重复安装Skill。')
+    (DIST / 'README.md').write_text(readme + note, encoding='utf-8')
+    version_file = DIST / '版本与校验.json'
+    version = json.loads(version_file.read_text(encoding='utf-8'))
+    version['deploymentReport20261106'] = dict(source='user forwarded Operations001 and WangGang001 reports',
+        skillsInstalledReported=True, skillsLoadedReported=True, databaseInstalledReported=False,
+        realMessageQueryVerified=False, interpretation='historical report before this database supplement; package preparation is not remote installation')
+    version['databaseSupplement'] = dict(zip=receipt, installInstructions=str(ROOT / 'docs/voc-database-delivery-20261106/发给运维001-补装研究数据库.md'))
+    write(version_file, version)
+    handoff = Path('F:/文档/新数字人交接')
+    (handoff / '11-研究数据库补装-20261106.md').write_text('''# 研究数据库补装 · 2026-11-06
+
+11月6日查询规则包仅含Skill,研究数据库须独立落地。两份Skill已由运维报告安装并经王刚001实际加载,查询因DATABASE_NOT_INSTALLED未开始。
+
+当前补装包:F:/文档/康师傅skill/ksf-voc-database-20261106-r1.zip;核验文件:同目录研究数据库补包校验-20261106.json。
+
+只补数据库到默认路径,不重装旧r2 Skill,不改身份、会话、网关或生产看板。按包内运维单安装/回读后,再由用户将王刚001续验单转入真实企微消息。数据库源于本地看板同字节快照,远端共享存储和后续自动同步尚未接通。
+''', encoding='utf-8')
+    print(json.dumps({key: value for key, value in receipt.items() if key != 'database'}, ensure_ascii=False, indent=2))
+
+
+if __name__ == '__main__':
+    parser = argparse.ArgumentParser(description=__doc__)
+    parser.add_argument('phase', choices=['prepare', 'validate', 'finalize'])
+    args = parser.parse_args()
+    globals()[args.phase]()

File diff suppressed because it is too large
+ 138 - 0
scripts/package-voc-library-r2.py


File diff suppressed because it is too large
+ 174 - 0
scripts/package-voc-library.py


+ 74 - 0
scripts/package-workshop-identity.mjs

@@ -0,0 +1,74 @@
+import fs from 'node:fs';
+import path from 'node:path';
+import crypto from 'node:crypto';
+import {execFileSync} from 'node:child_process';
+const output=process.argv[2];if(!output||!path.isAbsolute(output))throw Error('请指定交付目录绝对路径');
+const sha=x=>crypto.createHash('sha256').update(x).digest('hex'),name='ksf-aibot-identity-20261102';
+const staging=fs.mkdtempSync(path.resolve('work/identity-registry-delivery-')),root=path.join(staging,name);fs.mkdirSync(root);
+function write(file,content){const target=path.join(root,file);fs.mkdirSync(path.dirname(target),{recursive:true});fs.writeFileSync(target,content);}
+function copy(file){write(file,fs.readFileSync(file));}
+const skill='skills/ksf-workshop-runtime',skillNames=['SKILL.md','references/identity.md','references/workshop-tasks.md','references/write-tasks.md','runtime-lock.json','scripts/ksf-identity.mjs','scripts/ksf-secure.mjs','scripts/ksf-workshop.mjs','scripts/ksf.mjs'].sort();
+const entries=fs.readFileSync(skill+'/FILES.sha256','utf8').trim().split(/\r?\n/).map(line=>{const m=line.match(/^([a-f0-9]{64})  ([A-Za-z0-9_./-]+)$/);if(!m)throw Error('BAD_SKILL_MANIFEST');return{hash:m[1],file:m[2]};});
+if(JSON.stringify(entries.map(x=>x.file).sort())!==JSON.stringify(skillNames))throw Error('UNEXPECTED_SKILL_FILES');
+for(const x of entries){const file=skill+'/'+x.file;if(sha(fs.readFileSync(file))!==x.hash)throw Error('SKILL_BUILD_REQUIRED');copy(file);}
+copy(skill+'/FILES.sha256');copy('scripts/install-workshop-skill.mjs');copy('docs/workshop-identity.md');
+copy('docs/wecom-aibot-identity-handoff.md');
+for(const x of ['ksfStartWorkshopSession','ksfGetIngestContract'])copy('server/dist/'+x+'.js');
+copy('server/dist/identity-manifest.json');copy('server/scripts/deploy-identity.mjs');copy('server/scripts/platform-client.mjs');
+write('templates/agent-self.json',JSON.stringify({name:'替换为当前数字人的真实名称',role:'workshop',identityBasis:'operator_registration',runtimePlatform:'hermes',channel:'wecom',account:{platform:'wecom',tenantId:null,accountId:null,accountName:null,runtimeAgentId:null,wecomAppAgentId:null}},null,2)+'\n');
+write('templates/employee.json',JSON.stringify({name:'替换为当前员工显示名',identityBasis:'participant_registration',account:{platform:'wecom',tenantId:null,accountId:null,accountName:null,employeeNumber:null}},null,2)+'\n');
+write('checks/empty.json','{}\n');
+write('templates/channel-context.example.json',JSON.stringify({version:'ksf.wecom-aibot-context.v1',botId:'只可来自本实例订阅配置',senderId:'只可来自当前消息body.from.userid',chatType:'single',chatId:null,messageId:'只可来自当前消息body.msgid'},null,2)+'\n');
+write('INSTALL.txt',`康师傅工作坊 · aibot发送者身份接收升级 · 2026-11-02
+
+先读 docs/wecom-aibot-identity-handoff.md。本包支持无姓名/无企业ID时接收已有from.userid,但不含Hermes适配器补丁,不会自动生成可信消息上下文。此前通用登记包原件保留,本附件使用新名字。当前只涉及王刚001与运维001,不新增数字人。
+
+这是通用登记版,支持新员工、新数字人;不使用旧“双固定槽”包。只含 Skill 九文件和两函数代码,不含真实账号、凭据、私钥或 credential-binding.json。看板样式、静态发布、网关、s6、冻结 r7 不在范围内;不重复 G/H 或技术联调。
+
+一、核对附件
+按外部执行单的字节数和 SHA-256 校验本 ZIP,解压到 /opt/data/ksf-aibot-identity-20261102/,目录已存在则新建,不覆盖。cd 包根执行 sha256sum -c FILES.sha256,预期 23/23 OK;技能子目录 FILES.sha256 预期 9/9 OK。不以旧包代替。
+
+二、已有工作坊 Skill 的数字人:先升级,不跑业务
+1. 阅读 scripts/install-workshop-skill.mjs 和 skills/ksf-workshop-runtime/references/identity.md。执行 node scripts/install-workshop-skill.mjs。
+   仅升级已知原版/I-J/旧双槽/通用登记版,未知目标内容停止。保留既有加密绑定、原业务入口、runtime-lock、所有旧档案和动态 agents/participants 状态目录;重复安装 changed=[]。不自动迁移或删旧身份。
+2. skills_list / skillview 重读 ksf-workshop-runtime;在实际安装技能目录核对本包技能子清单九文件。
+3. node /opt/data/skills/ksf-workshop-runtime/scripts/ksf-identity.mjs status
+4. 从自己的可信运行配置和当前消息上下文提取白名单身份,参考 templates/agent-self.json,仅替换实际取得的字段。不能直接登记模板占位名,不能把另一机器人当自己,也不能说空账号模板代表读取成功。摘要在 /opt/data 下保存为 0600;未知账号 null,不全盘扫描配置。
+   王刚001 如实际身份相符则 name=王刚001、role=workshop;运维001 则 name=运维001、role=operations。其他新数字人填自己的真实名称及用途,不套用这两个名字。
+5. 新档案:node /opt/data/skills/ksf-workshop-runtime/scripts/ksf-identity.mjs register --input /实际绝对路径/自身摘要.json
+   已有通用档案:check --agent-profile <返回的完整Agent键> --input /实际绝对路径/自身摘要.json。
+   旧 agent-self.json 或 agent-self-workshop/operations.json:先读懂旧档案来源,再用 adopt-agent --input /旧档案绝对路径;原件不删除,迁入后 check 当前自身摘要。冲突停,不换键绕过。
+   无真实稳定 ID 用 operator_registration 生成 UUID;runtime_config/channel_context 必须有真实稳定 ID。返回 needsReview=true 只表示有同名候选,需明确 reuseKey 或 confirmNew 才完成登记。
+6. 优先使用aibot发送者自动登记:仅当运维已实现接入层逐消息上下文后,用 register-channel-employee --agent-profile <当前Agent完整键> --context /接入层当前消息私有文件。上下文结构见templates/channel-context.example.json,但不能填模板或用户贴文冒充真实消息。自身档案account.accountId必须为实际订阅botid,与上下文botId一致。
+   员工account保存identifierType=wecom_aibot_sender、scopeId=botid、accountId=from.userid,企业未知仍null。无姓名先显示员工待绑定,不要求员工回答账号;同sender跨单群复用,跨bot先独立。程序不校验企微签名,来源可信由接入层保证;不把channelContextOriginVerified=false说成身份来源已认证。
+   完全没有可信发送者时才走 register-employee --input /实际绝对路径/员工摘要.json,参考templates/employee.json;无ID用participant_registration,同名需明确复用或新建。已知员工档案用check-employee核对。不得从User提示文字或报告中的姓名推导当前员工。
+   本轮只做本地登记,不创建会话或业务成果。本地登记零网络、零数据库写入;跨容器本地 UUID 不会自动同步,需受控核对后复用同一档案。userId 仅可填本应用已有 _User.objectId,企微 userId 写 accountId。
+7. 回报安装、登记/check/status JSON、退出码及 invocationId,说明已取得/缺失字段名,不输出完整账号值或配置原文。
+8. 可用现有加密入口只读检查:
+   node /opt/data/skills/ksf-workshop-runtime/scripts/ksf-secure.mjs workshop-contract --input /实际解压目录/checks/empty.json --request-id KSF-身份合同-20261102-L
+   预期 workshop.identitySchema=ksf.agent-self.v1,aibot还需channelIdentitySchema=ksf.wecom-aibot-sender.v1;未就绪照实报告,不执行 workshop-start/save。
+
+三、新数字人或运维001没有既有 Skill/绑定
+本包安装器用于已知基线升级,不是首次凭据开通工具。不复制王刚001的 credential-binding.json/私钥。可直接用本包 skills/ksf-workshop-runtime/scripts/ksf-identity.mjs 完成本地自身登记,它不需要服务凭据;实际业务调用前另配该实例受控授权的 Skill 与加密绑定。
+
+四、运维001:只做两个云函数的发布预检
+1. 阅读 server/scripts/deploy-identity.mjs 和 server/dist/identity-manifest.json,仅涉及两个现有 Function.code:ksfStartWorkshopSession、ksfGetIngestContract,不更新 /opt/kangshifu-web、不重启 pm2。
+2. 在已有受控管理连接的环境将 KSF_CONNECTION_CONFIG 指向现有键值表配置文件;字段格式同 platform-client.mjs。不在聊天中传密钥。
+3. node server/scripts/deploy-identity.mjs
+   预期 readOnly=true、operation=read-only-plan、functions 恰好两项、schemaChanges=permissionChanges=businessWrites=0。只接受已审计基线或目标版本,未知代码停止。
+4. 返回原始预检 JSON 与退出码,本轮不执行 --apply。正式发布命令另行执行 node server/scripts/deploy-identity.mjs --apply;工具备份、回读、检查结构权限,失败只在目标仍为本次代码时回退,不覆盖并发更改。
+5. 运维接入准备:按handoff文档在隔离副本准备适配器/hook补丁及离线测试,提供当前版本/相关文件SHA-256、具体代码差异、逐消息隔离/过期机制与回退方案。本包没有现场源码,不按报告行号盲补丁;本轮不改客户容器、不重启、不发送测试消息。
+
+五、后续联合验收(本轮不执行)
+两函数发布后先读合同,再从新的真实企微消息确认当前员工与 Agent 档案。workshop-start 输入 participant:{profileKey:"完整员工键"},命令传 --agent-profile <完整Agent键>;不再传 --agent-profile workshop 或 --slot。
+沿用尚未执行的 KSF-企微实测-20261101-K 业务键,S1待查清单从start就用sample,save继承;身份人工登记不等于材料manual。按真实sessionId/artifactId回读,再由管理端独立核对私有归属快照和公开看板,不把本地登记当远端入库或平台认证。
+原服务令牌到期为北京时间2026-11-06 18:42,本次未续期。保留写入保护、不改网关、不重启、不自动清理。
+`);
+const files=[];function walk(dir){for(const e of fs.readdirSync(dir,{withFileTypes:true})){const file=path.join(dir,e.name);if(e.isSymbolicLink())throw Error('SYMLINK_IN_PACKAGE');if(e.isDirectory())walk(file);else files.push(path.relative(root,file).replaceAll('\\','/'));}}walk(root);files.sort();if(files.length!==23)throw Error('UNEXPECTED_PACKAGE_SIZE');
+for(const file of files){if(/(?:credential-binding|recipient-private|\.pem$|\.env$|agent-self-(?:workshop|operations)\.json$)/.test(file)||/-----BEGIN (?:RSA )?PRIVATE KEY-----/.test(fs.readFileSync(path.join(root,file),'utf8')))throw Error('PRIVATE_MATERIAL_IN_PACKAGE');}
+write('FILES.sha256',files.map(file=>sha(fs.readFileSync(path.join(root,file)))+'  '+file).join('\n')+'\n');
+const python=process.env.KSF_PACKAGE_PYTHON||'C:/Users/JSHY/AppData/Local/Python/pythoncore-3.14-64/python.exe',packed=path.join(staging,name+'.zip');
+execFileSync(python,['-c','import sys,zipfile,pathlib; root=pathlib.Path(sys.argv[1]); z=zipfile.ZipFile(sys.argv[2],"w",zipfile.ZIP_DEFLATED); [(lambda p: z.writestr(zipfile.ZipInfo(p.relative_to(root).as_posix(),(2026,11,2,0,0,0)),p.read_bytes(),compress_type=zipfile.ZIP_DEFLATED))(p) for p in sorted(root.rglob("*")) if p.is_file()]; z.close(); z=zipfile.ZipFile(sys.argv[2]); assert z.testzip() is None',root,packed],{windowsHide:true});
+fs.mkdirSync(output,{recursive:true});const file=path.join(output,name+'.zip'),bytes=fs.readFileSync(packed);if(fs.existsSync(file)){if(sha(fs.readFileSync(file))!==sha(bytes))throw Error('DELIVERY_ALREADY_EXISTS');}else fs.copyFileSync(packed,file,fs.constants.COPYFILE_EXCL);
+const result={at:new Date().toISOString(),file,sizeBytes:bytes.length,sha256:sha(bytes),manifestEntries:files.length,skillManifestEntries:skillNames.length,containsCredentials:false,serverPublished:false,hermesInstalled:false,adapterModified:false,adapterIntegrationComplete:false,channelVerifiedLive:false};
+fs.writeFileSync(path.join(output,name+'.json'),JSON.stringify(result,null,2)+'\n');console.log(JSON.stringify(result,null,2));

+ 84 - 0
scripts/package-workshop.mjs

@@ -0,0 +1,84 @@
+import fs from 'node:fs';
+import path from 'node:path';
+import crypto from 'node:crypto';
+import {execFileSync} from 'node:child_process';
+const output=process.argv[2];if(!output||!path.isAbsolute(output))throw Error('请指定交付目录的绝对路径');
+const sha=x=>crypto.createHash('sha256').update(x).digest('hex');
+const destination=path.resolve(output);fs.mkdirSync(destination,{recursive:true});
+const staging=fs.mkdtempSync(path.resolve('work/package-'));
+const python=process.env.KSF_PACKAGE_PYTHON||'C:/Users/JSHY/AppData/Local/Python/pythoncore-3.14-64/python.exe';
+const results=[];
+function write(root,name,content){const file=path.join(root,name);fs.mkdirSync(path.dirname(file),{recursive:true});fs.writeFileSync(file,content);}
+function copy(root,name){write(root,name,fs.readFileSync(name));}
+function pack(name,prepare){
+ const root=path.join(staging,name);fs.mkdirSync(root);prepare(root);
+ const files=[];function walk(dir){for(const e of fs.readdirSync(dir,{withFileTypes:true})){const p=path.join(dir,e.name);if(e.isDirectory())walk(p);else files.push(path.relative(root,p).replaceAll('\\','/'));}}walk(root);
+ files.sort();write(root,'FILES.sha256',files.map(n=>sha(fs.readFileSync(path.join(root,n)))+'  '+n).join('\n')+'\n');
+ const zip=path.join(destination,name+'.zip');
+ execFileSync(python,['-c','import sys,zipfile,pathlib; root=pathlib.Path(sys.argv[1]); z=zipfile.ZipFile(sys.argv[2],"w",zipfile.ZIP_DEFLATED); [(z.write(p,p.relative_to(root).as_posix())) for p in sorted(root.rglob("*")) if p.is_file()]; z.close(); z=zipfile.ZipFile(sys.argv[2]); assert z.testzip() is None',root,zip],{windowsHide:true});
+ const bytes=fs.readFileSync(zip);results.push({name,file:zip,sizeBytes:bytes.length,sha256:sha(bytes),manifestEntries:files.length});
+}
+pack('ksf-workshop-skill-20261101',root=>{
+ const source='skills/ksf-workshop-runtime';
+ for(const line of fs.readFileSync(source+'/FILES.sha256','utf8').trim().split('\n'))copy(root,source+'/'+line.slice(66));
+ copy(root,source+'/FILES.sha256');copy(root,'scripts/install-workshop-skill.mjs');
+ write(root,'checks/read-only-project-search.json',JSON.stringify({className:'KfProject',filters:{dataMode:'sample'},limit:20},null,2)+'\n');
+ write(root,'INSTALL.txt',`康师傅工作坊 Skill 升级 · 2026-11-01
+
+此包仅更新数字人的工作坊执行入口,不包含凭据、私钥、credential-binding.json 或真实研究素材。
+保留已有加密绑定、原 ksf.mjs、runtime-lock、冻结 r7。无需改网关、配置或重启。
+更新 SKILL.md 和加密路由,新增工作坊脚本与任务格式;安装器会备份旧文件,发现未知修改先停,不覆盖。
+
+1. 先用随包执行单的大小及 SHA-256 校验 ZIP,不符即停,不用旧包代替。
+2. 解压到 /opt/data/ksf-workshop-skill-20261101/(若已存在另建干净目录,不覆盖),cd 到包根。
+3. sha256sum -c FILES.sha256。顶层应 11/11 OK;技能子清单应 7/7。
+4. 阅读 scripts/install-workshop-skill.mjs,执行:
+   node scripts/install-workshop-skill.mjs
+   预期 ok=true,credentialBindingPreserved=true,legacyEntryPreserved=true,gatewayModified=false。
+   当前已知基线首次更新四项;已安装同版本则 changed=[],不重复备份。
+5. skills_list 发现,再 skillview 重新读取 ksf-workshop-runtime。文件安装不代表发现或企微调用完成。
+6. cd /opt/data/skills/ksf-workshop-runtime,使用解压包内技能子 FILES.sha256 验证实际安装七文件。
+7. 从新的企微消息按已加载技能运行只读核验:
+   node /opt/data/skills/ksf-workshop-runtime/scripts/ksf-secure.mjs status --request-id KSF-工作坊升级-20261101-I
+   node /opt/data/skills/ksf-workshop-runtime/scripts/ksf-secure.mjs workshop-search --input /opt/data/ksf-workshop-skill-20261101/checks/read-only-project-search.json --request-id KSF-工作坊只读-20261101-J
+   若实际解压目录不同,只改上面 input 目录。两条各自报原始 JSON、两层 invocationId、退出码;下划线原样保留,不输出凭据。
+   search 应由新 ksf-workshop.mjs 执行、readOnly=true;查询存在的 sample 项目不是新增样例。
+8. 安装和只读核验停在这里。后续另发真实工作坊任务,取真实发送者与当前 Agent,登记会话、保存一份阶段成果,并回读对应编号。
+   不把“王刚001(联调模拟)”当实际 Agent;新包不会自动重演管理机的技术样例。
+
+运行约束:保持 HERMES_WRITE_SAFE_ROOT;只在 /opt/data 下准备任务。无稳定身份时按技能做课堂登记,不伪造账号。
+公开看板不需登录;课堂成果是 workshop,正式报告确认仍保持独立流程。sample 不能升级成真实资料。
+令牌原到期日为北京时间 2026-11-06 18:42,本次没有续期;过期则停止并由管理方更新信封。
+`);
+});
+pack('ksf-board-data-20261101',root=>{
+ const files=['src/app.config.js','src/services/board-service.js','src/services/workshop-data.js','src/core/kfs/build.js','src/core/library/reports.js','src/core/opportunity/opportunity.js','src/core/taxonomy/taxonomy.js','src/pages/view-members.js','lib/vendor/signal-polyfill/v0.2.2/dist/index.js'];
+ for(const file of files)copy(root,file);
+ write(root,'PUBLISH.txt',`康师傅看板数据接入发布 · 2026-11-01
+
+由王刚001或平台后台按现有站点流程发布,本包尚未推送 Git 或部署前端。
+基于仓库提交 7d01b5cf2249decb63cfbde95e26dfedad86290e。共九个运行文件,无凭据。
+保留公开访问,不增加登录。未修改 CSS、设计令牌、组件样式或布局。
+成员页沿用原表格新增逐项目进展,显示负责人、阶段、成果、方案保存情况与卡住/课堂完成。
+
+发布前 sha256sum -c FILES.sha256。先备份对应旧文件;如线上已继续更新,先合并差异,不能覆盖其他人的改动。
+按包内相对路径合并到站点源码并发布这九文件,不要直接上传包根说明和清单为站点首页。
+新增 workshop-data.js;务必带上 signal-polyfill 的 dist/index.js(旧仓库 dist 忽略规则曾漏收此运行依赖)。
+Git 发布时保留仓库 .gitignore 对该 index.js 的例外,不能只提交文件夹。
+index.html 不变;保留现有 base、importmap 和路由回退。更新后按现有发布渠道刷新文件缓存。
+服务端八个工作坊云函数已经上线,不需再次部署、不改 Schema/表权限、不往前端添加服务令牌。
+
+验收:匿名浏览器打开 /、/members、/pool、/stages、/report,均能读取。
+在 /members 找“工作坊联调(非学员)”、项目“【样例】拌面新品链路技术联调”,应是 S5 / 课堂完成。
+报告 /report/5u4xzJW5DY 应显示样例、Agent“王刚001(联调模拟)”以及未正式确认说明。
+机会 /pool/7TnUg76JOI 与项目 Z-IEjZg0qZ 的关联应可回读。
+浏览器 Network 应看到匿名 POST /api/functions path=/ksfGetPublicBoard,不能发送服务令牌。
+核桌面与390px手机、刷新与深链接;现有评分/决策沿用旧行为。
+
+以上是管理机明确 sample 的技术联调,不是真实员工研究,也不是企业审批或正式报告发布。
+前端发布和 Hermes 安装后,还需新的企微消息实测员工→当前Agent→阶段成果→看板,才算现场通道完整验收。
+`);
+});
+const receipt={at:new Date().toISOString(),packages:results,containsCredentials:false,hermesInstalled:false,frontendPublished:false};
+fs.writeFileSync(path.join(destination,'ksf-workshop-delivery-20261101.json'),JSON.stringify(receipt,null,2));
+console.log(JSON.stringify(receipt,null,2));

+ 242 - 0
scripts/prepare-S4-classroom-next.py

@@ -0,0 +1,242 @@
+"""Prepare linked classroom workflow templates after public-source acceptance.
+
+No business API calls. IDs returned by future writes stay null until bound in
+the current actor's real-message execution. Never modify previous versions.
+"""
+from copy import deepcopy
+import hashlib
+import json
+from pathlib import Path
+import zipfile
+
+ROOT = Path(__file__).resolve().parents[1]
+DOCS = Path('F:/文档/工作坊当前资料')
+OUT = ROOT / 'work/S4-classroom-next-20261103'
+ZIP = DOCS / 'ksf-S4-classroom-next-20261103.zip'
+assert not OUT.exists() and not ZIP.exists()
+OUT.mkdir()
+readback = json.loads((ROOT / 'work/S4-public-readback-20261103.json').read_text(encoding='utf-8'))
+assert readback['ok'] and readback['systemStage'] == 'S3'
+assert readback['projects'] == readback['opportunities'] == readback['s4Artifacts'] == 0
+existing = json.loads((ROOT / 'work/first-live-S3-v2-template.json').read_text(encoding='utf-8'))
+records = json.loads((ROOT / 'work/S4-public-record-inspection-20261103.json').read_text(encoding='utf-8'))['records']
+
+
+def put(name, data):
+    with (OUT / name).open('xb') as stream:
+        stream.write(data.encode('utf-8') if isinstance(data, str) else data)
+
+
+def put_json(name, data):
+    put(name, json.dumps(data, ensure_ascii=False, indent=2) + '\n')
+
+
+def para(title, text):
+    return {'kind': 'para', 'title': title, 'text': text}
+
+
+def table(title, cols, rows):
+    return {'kind': 'table', 'title': title, 'data': {'cols': cols, 'rows': rows}}
+
+
+base = 'KSF-真实资料-20261102-01'
+source_ids = ['m1OQx9aynA', 'QdeMcyTTjX', 'PjORcvaRHn', '5S_Y4IgT5q']
+evidence = {key: deepcopy(existing['report']['evidence'][key]) for key in ['consumer-cq-03', 'consumer-cq-04']}
+evidence['public-p1'] = {
+    'sourceId': 'PjORcvaRHn', 'claim': '只证明苏宁公开图片页参数块显示净含量585克、袋装,不证明实物或当前在售。',
+    'quotes': [{'text': '净含量\n585克\n包装\n袋装', 'ref': 'https://m.suning.com/bigimages/12447641634.html;2026-11-03北京时间采集;evidenceId=xHGV_mRWcG;页面对象P1,不合并SKU'}]
+}
+evidence['public-p2'] = {
+    'sourceId': '5S_Y4IgT5q', 'claim': '只记录2023年作者对自己煮制的叙述,不替代包装说明,不作为本轮试验结果。',
+    'quotes': [{'text': '包装上写的煮面6-7分钟,实测写少了,煮了10分钟面才软。', 'ref': 'https://post.smzdm.com/p/aeqxm74k/;页面发表时间2023-12-18 21:20:15,时区未知;evidenceId=CeAEIwymTa'}]
+}
+pending = [
+    '当前拟研究实物及版本、批次、获取渠道与日期未知;网页对象P1不等于已取得样品。',
+    '旧cq-03/cq-04、P1和历史P2之间的精确SKU对应关系未知,不合并为同款同批次。',
+    '份量与用餐目的、既有搭配习惯、提前或吃后补主食动机仍需实际观察;没有本轮试吃结果。',
+    '具体人群、使用目的及场景分布、需求强度、支付意愿与市场普遍程度未知。',
+    '燃魂具体常规/加量版本及当前可取得情况未知,不把宽品类公告当燃魂版本证明。',
+    '如果开展人员研究,目标、对象、资源、样本与记录/比较方法及预先判断规则待定。',
+    '配方、原料、产线、工艺、成本及相应合规资料未取得,不评定量产可行性。',
+    '煮制告知的购买路径与用户理解未测试;香辣留水方向另需具体商品,不复用燃魂条件。'
+]
+scope = '公开资料支持的课堂流程测试。候选问题尚未证明;不声称员工已正式确认、企业已立项或实物试验已完成。'
+portion = '对明确的一份,在当次实际用餐目的下是否需要额外补主食?“正餐”是待记录的目的之一,不预设人人都把它当一顿饭。'
+quotes_rows = [[entry['claim'], entry['quotes'][0]['text'], entry['sourceId']] for entry in evidence.values()]
+label_note = 'P1单袋正面图的人工目视标示为“净含量:面饼+配料117克 面饼:90克”和“煮面6-7分钟”;这是证据xHGV_mRWcG的locator.imageReadings及images/suning-05.jpg所示,不是网页rawText中的引文或本轮称量。标题117g×5与参数585克算术一致,不确认当前实物。P2历史背面图单列,不与P1拼成同SKU。'
+method = '公开资料一致性与证据缺口审查;引用4份已入库来源各1段主引文。包装照片读取与网页文字分开;没有招募、试吃、称量、问卷、A/B测试或统计估计。'
+source_note = 'cq-03/cq-04为2023年两个显示账号的历史自述;P1为2026-11-03北京时间观察到的苏宁公开高清图片页,P2为同日采集的2023年帖子。购买、真实身份、当前库存和SKU对应未核;完整原件见42号、数据库核对见45号。'
+
+
+def make_report(task_suffix, title, stage, artifact_type, chapters, project=False, executive=None):
+    summary = executive or [scope, portion, '新资料已入库并独立回读,但当前市场需求、实物与量产结论仍未知。']
+    rendered = [title, *summary]
+    for chapter in chapters:
+        rendered.append(chapter['title'])
+        for section in chapter['sections']:
+            rendered.append(section['title'])
+            if section['kind'] == 'para':
+                rendered.append(section['text'])
+            else:
+                rendered.extend(' | '.join(str(value) for value in row) for row in section['data']['rows'])
+    raw = '\n\n'.join(rendered)
+    result = {
+        'sessionId': None, 'taskKey': base + ':' + task_suffix, 'version': 1,
+        'stageCode': stage, 'title': title, 'artifactType': artifact_type,
+        'category': '拌面', 'rawText': raw, 'sourceIds': source_ids[:],
+        'pendingQuestions': pending[:],
+        'report': {'subtitle': '历史个案与公开页面的桌面评估,实物和市场验证待补',
+                   'executive': summary, 'method': method, 'sources': source_note,
+                   'chapters': chapters, 'evidence': deepcopy(evidence)}
+    }
+    if project:
+        result['projectId'] = None
+    return result
+
+
+candidate = make_report('S3-份量候选', '燃魂份量与用餐目的适配|课堂待验证候选', 'S3', 'opportunity_card', [
+    {'id': 'candidate', 'title': '拟选课堂方向', 'stage': 'S3', 'sections': [para('问题', portion), para('证据程度', 'cq-03/cq-04形成历史重复线索,不证明普遍需求。P1/P2补对象标示与制备旁证,尚不证明实际份量适配。'), para('上下文', 'cq-03同帖还有价格正面描述及青豆不满,并添加挂面、菠菜、煎蛋;cq-04肯定味道且另加蛋肠。搭配习惯和补偿动机可能并存。'), table('四段原文与来源', ['支持范围', '逐字引文', 'sourceId'], quotes_rows)]},
+    {'id': 'limits', 'title': '边界与待补', 'stage': 'S3', 'sections': [para('网页与包装', label_note), para('本轮选择含义', '供本次软件流程测试选择的课堂候选;执行本包前未宣称员工已经选定。煮制告知与香辣留水继续留在原S3第2版,不因本次取舍被否决。'), table('待补问题', ['序号', '待核实问题'], [[str(i + 1), q] for i, q in enumerate(pending)])]}
+])
+candidate['opportunity'] = {
+    'key': base + ':portion-fit', 'title': '燃魂份量与用餐目的适配|待验证',
+    'demandSummary': '检验一份与实际用餐目的是否适配;历史个案仅作线索,是否需要补主食待验证,不预定加量方案。',
+    'targetAudience': '具体人群未验证;后续按实际用餐目的与研究目标界定。',
+    'usageScenario': '正餐、加餐等用途待记录,不预设统一情境。',
+    'fusionDefinition': '拌面×具体人群待定×用餐目的待定;公开资料支持的候选问题,不是已确认机会。'
+}
+
+baseline = make_report('S3-项目基线', '燃魂份量适配|项目S3证据基线', 'S3', 'opportunity_card', [
+    {'id': 'baseline', 'title': '项目范围与证据基线', 'stage': 'S3', 'sections': [para('问题', portion), para('范围', scope), para('对象', label_note), table('已有主引文', ['支持范围', '逐字引文', 'sourceId'], quotes_rows)]},
+    {'id': 'next', 'title': '进入桌面评估的依据与缺口', 'stage': 'S3', 'sections': [para('转阶段条件', '这份基线保存到本次真实返回的projectId下后,可按合同进入S4公开资料桌面评估。进入S4仅表示开始评估,不是已经验证需求或完成试吃。'), table('待补问题', ['序号', '待核实问题'], [[str(i + 1), q] for i, q in enumerate(pending)])]}
+], project=True)
+
+assessment_rows = [
+    ['规格展示一致性', 'P1标题117g×5、参数585克;单袋图含料117克、面饼90克', '页面标示算术一致;重量类型分开', '不证明现货、实发版本、实物重量或旧帖同SKU'],
+    ['份量问题线索', 'cq-03/cq-04两篇2023年自述', '仅支持保留份量与用途适配问题', '没有本轮食用观察,不算市场比例或需求强度'],
+    ['煮制说明存在性', 'P1图片6—7分钟;P2历史包装背面步骤', '两来源可找到煮制信息,分别保留时点', '不证明购买路径已讲清,不认定全系列当前说明'],
+    ['制备表现', 'P2作者说自己煮10分钟才软', '保留历史个体叙述', '没有控制水量/火力/偏好,不判包装错误'],
+    ['工艺成本合规', '企业资料未取得', '无法评定,保持未知', '不填写数值评分、可量产或低成本结论']
+]
+s4 = make_report('S4', '燃魂份量适配|公开资料桌面评估 第1版', 'S4', 'assessment_report', [
+    {'id': 'scope', 'title': '评估范围与已知对象', 'stage': 'S4', 'sections': [para('本轮实际工作', '已完成公开资料采集、原件核对及入库独立验收。本报告整合这些已有结果,不新增消费者试验数据。'), para('问题', portion), para('网页及图片依据', label_note)]},
+    {'id': 'evidence', 'title': '证据与核对结果', 'stage': 'S4', 'sections': [table('主引文', ['支持范围', '逐字引文', 'sourceId'], quotes_rows), table('桌面评估', ['评估项', '依据', '结果', '不能判定'], assessment_rows)]},
+    {'id': 'validation', 'title': '假设处理与后续验证条件', 'stage': 'S4', 'sections': [para('份量方向', '保留待验证,不据重量认定吃饱程度。提前加主食与吃后补食都记录;习惯与补偿可并存,原话与研究者解释分开。未实施的方案不产出结果。'), para('其他方向', '煮制告知收窄为真实购买路径的可见性与理解,尚无受试者测试;香辣留水是另一具体商品问题,不复用燃魂参数。'), para('继续、调整或停止', '若未来在明确对象与情境下取得额外补主食行为及份量原因原话,可继续研究并考察其他解释;动机混合或条件不足则调整问题,证据不足不等于证明无效。样本、方法和门槛待目标及资源确定后预先写明。')]},
+    {'id': 'pending', 'title': '待核实问题与阶段结论', 'stage': 'S4', 'sections': [table('待补问题', ['序号', '待核实问题'], [[str(i + 1), q] for i, q in enumerate(pending)]), para('结论', '公开资料桌面评估已形成;当前需求、实物试验与量产验证未完成。课堂项目保持S4 in_progress,不标completed,不推进S5,不据此正式发布。')]}
+], project=True, executive=[scope, '本报告为公开资料一致性和证据缺口评估,含已完成核对;不是空白试验计划,也不是实物试验报告。', '4份已入库来源、4段主引文;图示读取另有原图定位。当前SKU与历史帖子不合并。', '桌面评估可继续;当前人群、场景、需求强度、成本及量产结论仍未知。'])
+
+tasks = {
+    '01-candidate-save.template.json': candidate,
+    '02-project-create.template.json': {'sessionId': None, 'opportunityId': None, 'projectKey': base + ':portion-fit-project', 'title': '燃魂份量适配|公开资料课堂测试'},
+    '03-project-S3-save.template.json': baseline,
+    '04-project-S4-progress.template.json': {'sessionId': None, 'projectId': None, 'toStage': 'S4', 'state': 'in_progress', 'note': '本项目已保存S3证据基线,现进入公开资料桌面评估;实物与需求验证待补,不代表试吃完成或企业批准。'},
+    '05-project-S4-save.template.json': s4
+}
+
+# Static preflight verifies references, no unknown IDs, visible pending table and
+# exact quotes against accepted sources; it does not claim runtime/server save.
+old_sources = {entry['sourceId']: entry['quotes'][0]['text'] for entry in existing['report']['evidence'].values()}
+new_sources = {r['objectId']: r['record']['rawText'] for r in records if r['className'] == 'KfSource'}
+for name, task in tasks.items():
+    assert task['sessionId'] is None
+    if 'projectId' in task:
+        assert task['projectId'] is None
+    if 'report' in task:
+        assert task['sourceIds'] == source_ids and len(task['report']['evidence']) == 4
+        assert task['pendingQuestions'] == pending and len(pending) == 8
+        for entry in task['report']['evidence'].values():
+            assert entry['sourceId'] in source_ids
+            for quote in entry['quotes']:
+                assert quote['text'] in (new_sources.get(entry['sourceId']) or old_sources[entry['sourceId']])
+                assert quote['text'] in task['rawText']
+        visible = [s for ch in task['report']['chapters'] for s in ch['sections'] if s.get('title') == '待补问题']
+        assert len(visible) == 1
+        assert visible[0]['data']['rows'] == [[str(i + 1), q] for i, q in enumerate(pending)]
+        assert 'opportunity' not in task or task is candidate
+        assert all(s['kind'] in ('para', 'table') for ch in task['report']['chapters'] for s in ch['sections'])
+    put_json(name, task)
+assert all(0 < len(value) <= 200 for value in candidate['opportunity'].values())
+put_json('preflight.json', {'ok': True, 'businessApiCalls': 0, 'businessWrites': 0, 'saved': False,
+                          'browserVerified': False, 'localStructureOnly': True, 'templates': list(tasks),
+                          'inputBinding': 'sessionId null; projectId/opportunityId null where required',
+                          'reportCount': 3, 'sourcesPerReport': 4, 'quotesPerReport': 4, 'pendingPerReport': 8})
+
+handoff = '''运维001,请核验本包并准备后续课堂流程的固定模板。当前仍为S3,本包未执行任何写入。候选沿用S3已列“燃魂份量与用餐目的适配”,仅拟作本次软件测试方向,不宣称员工已经正式选定;用户转交本单用于准备,不由运维代作业务选择或写入。
+
+先读45号独立验收。两份新资料已导入且Codex独立核查通过:P1 PjORcvaRHn/xHGV_mRWcG,P2 5S_Y4IgT5q/CeAEIwymTa,禁止重导。旧cq-03 m1OQx9aynA/_9mUml0wmh、cq-04 QdeMcyTTjX/2bFGP7axMo沿用。共4来源、4主引文,候选/项目基线/S4报告均保留8条缺口,不含评分或实物结果。
+
+将5份template原样落地/opt/data/ksf-executor-20261103/S4-classroom-next-01/,root:root0644且hermes实读,核清单与完整哈希。模板sessionId均为null,涉及opportunityId/projectId处也为null,不能直接执行,不在本轮生成可执行输入。
+
+按现网合同核workshop-save/project-create/project-progress及S4报告,复核同一真实会话btrFqkz0yw、当前王刚身份及S1/S2/S3归属。报告仅允许绑定规定来源,4引文逐字存在;旧两条可沿用45号前后的已核记录,不必为重复写稿再采集。发现与当前合同不同,先报差异,不改Skill/Function/schema或放宽校验。
+
+历史检查范围:本次候选系列KSF-真实资料-20261102-01:S3-份量候选 v1;机会系列KSF-真实资料-20261102-01:portion-fit;项目键KSF-真实资料-20261102-01:portion-fit-project;项目S3系列KSF-真实资料-20261102-01:S3-项目基线 v1;S4系列KSF-真实资料-20261102-01:S4 v1。检查47号全部requestId及目标文件、在途。已有对象或回执先按编号回报,不删除、不换键。跨日不新建会话。
+
+留存目标/opt/data/ksf-workshop-private/S4-classroom-next-01/仅在不存在时新建,hermes0700,先确认为空。保留既有S4-public-import/及所有S1/S2/S3/S4-live-00回执。
+
+关键顺序:候选S3报告带opportunity创建卡→读取卡→project-create并读取项目→保存带新projectId的S3基线并读取→project-progress进入S4→读取项目→保存项目下S4桌面评估并读取→最终project-status。现有S3第2版没有projectRef,不能代替项目S3基线;不补写旧记录。
+
+本轮只核包、落地、合同、归属、来源、历史及在途。不执行save/project-create/project-progress;不重导、不建产品/分类、不采购或试吃、不更新S5或正式发布。回报5模板字节/完整哈希及各预检结果,完成后按47号交王刚001在真实消息执行。
+'''
+
+execution = '''王刚001,本单仅在用户选择“燃魂份量与用餐目的适配”用于本次课堂流程测试并转交执行、且46号预检通过后使用。用户尚未选择、身份不符、已有本任务回执或在途时先停核,不擅自称员工已选定。它创建课堂候选卡与项目,不是企业立项;只做公开资料桌面评估,不声称实物/市场验证。
+
+目录:模板/opt/data/ksf-executor-20261103/S4-classroom-next-01/;输入与回执/opt/data/ksf-workshop-private/S4-classroom-next-01/。本轮不重新import任何来源。每一步成功并核对后才做下一步,不把命令批量无条件连跑。
+
+一、身份与固定模板
+无参数调用ksfregistercurrent_employee核实本条完整agentProfileKey与会话btrFqkz0yw及旧S3归属一致。实读5份模板核FILES.sha256、47号全文及所有目标文件不存在;不从截断键或口头姓名拼身份。保留当前会话,不执行start。
+模板以外的固定内容不得改写。读入内存后仅绑定sessionId=btrFqkz0yw;涉及opportunityId/projectId必须用本次已验证返回值。逐字段差异严格等于以下各步列出的字段;使用O_EXCL新建0600输入,目录0700。已存在即停查,禁止覆盖或删除。每次stdout/stderr/退出码、身份及两层回执独占0600留存,禁止输出凭据。
+执行入口统一为node /opt/data/skills/ksf-workshop-runtime/scripts/ksf-secure.mjs;所有业务写命令带--agent-profile <本条完整agentProfileKey>,读命令可不带。
+
+二、依次执行(每条最多一次)
+01. 从01-candidate-save.template.json仅填sessionId,新建01-candidate-save.json;workshop-save,requestId=KSF-真实资料-20261102-01:S3-份量候选:save:v1。应返回新artifactId/sourceId/opportunityId,projectId=null。这是新系列v1,不修改S3第2版,不冒报S3第3版。
+02. 取新artifactId,独占生成{"className":"KfArtifact","objectId":"<返回值>"}到01-candidate.read.json;workshop-read,requestId=KSF-真实资料-20261102-01:S3-份量候选:read:v1。
+03. 取新opportunityId,独占生成KfOpportunity精确输入01-opportunity.read.json;workshop-read,requestId=KSF-真实资料-20261102-01:portion-fit:read:v1。核claimKind=hypothesis、status=candidate、opportunitySeriesKey=KSF-真实资料-20261102-01:portion-fit、version1、session归属、linkedArtifacts包含步骤01新报告、全部opportunity字段等于模板,未连项目。
+04. 02-project-create.template.json仅填sessionId与步骤03已核opportunityId,新建02-project-create.json;project-create,requestId=KSF-真实资料-20261102-01:portion-fit-project:create:v1。应返回新projectId,stageCode=S3、enterpriseApproval=false;保留eventId。仅创建课堂项目。
+05. 独占新建02-project-status.json={"sessionId":"btrFqkz0yw","projectId":"<步骤04返回值>"};project-status,requestId=KSF-真实资料-20261102-01:portion-fit-project:read-created:v1。核项目归属、live、S3、opportunityId为步骤03,暂无项目成果或方案。项目创建会把课堂机会卡status改为approved、linkedProject填新项目;approved只作课堂状态,不说企业审批通过。
+06. 03-project-S3-save.template.json仅填sessionId与已核projectId,新建03-project-S3-save.json;workshop-save,requestId=KSF-真实资料-20261102-01:S3-项目基线:save:v1。应返回新S3 artifactId/sourceId,projectId同本次项目、opportunityId=null。
+07. 独占新建03-project-S3.read.json按新artifactId精读;workshop-read,requestId=KSF-真实资料-20261102-01:S3-项目基线:read:v1。核projectRef/projectId确实指本项目,这是转阶段的必要前置,不用旧S3报告代替。
+08. 04-project-S4-progress.template.json仅填sessionId与projectId,新建04-project-S4-progress.json;project-progress,requestId=KSF-真实资料-20261102-01:portion-fit-project:to-S4:v1。应返回stageCode=S4、progressState=in_progress、enterpriseApproval=false,保留eventId。
+09. 使用已保存的02-project-status.json只读;project-status,requestId=KSF-真实资料-20261102-01:portion-fit-project:read-S4:v1。核阶段S4/in_progress,项目S3基线仍在。此时不声称S4报告已保存。
+10. 05-project-S4-save.template.json仅填sessionId与projectId,新建05-project-S4-save.json;workshop-save,requestId=KSF-真实资料-20261102-01:S4:save:v1。应返回S4 artifactId/sourceId,同项目,opportunityId=null,version1。
+11. 独占新建05-project-S4.read.json按返回artifactId精读;workshop-read,requestId=KSF-真实资料-20261102-01:S4:read:v1。
+12. 使用02-project-status.json;project-status,requestId=KSF-真实资料-20261102-01:portion-fit-project:read-final:v1。核项目S4/in_progress、所属S3基线与S4报告都有、无新品方案、不推进S5或completed。
+
+三、命令格式(由当前步已独占新建的文件和上列requestId填入,不改变业务键)
+写:node /opt/data/skills/ksf-workshop-runtime/scripts/ksf-secure.mjs <workshop-save或project-create或project-progress> --agent-profile <本条完整agentProfileKey> --input /opt/data/ksf-workshop-private/S4-classroom-next-01/<上述本步输入名> --request-id <上述本步requestId>
+读:node /opt/data/skills/ksf-workshop-runtime/scripts/ksf-secure.mjs <workshop-read或project-status> --input /opt/data/ksf-workshop-private/S4-classroom-next-01/<上述本步读取输入名> --request-id <上述本步requestId>
+
+四、各报告保存及回读必核
+外层ok、entryResult.ok、EXIT=0;保存取sessionId/artifactId/sourceId/opportunityId/projectId。回读逐项核stageCode/version1/reportSeriesKey/artifactType/live/dashboardState=workshop/归属session、projectRef(后两报告)、tags含拌面与课堂过程成果、rawText、sourceIds恰4个、report.chapters/evidence/method/sources/subtitle与模板逐项一致,8条pendingQuestions及可见表8行逐字对应。4主引文逐字、sourceId对应正确。report.executive仅允许服务在前方加课堂声明;report.title/generatedAt为服务新增。previousVersionRef可null,不声称Pointer版本链。
+保存响应boardVisible为课堂可见;不要求回读出现此字段。dashboardPublishedAt=null、employeeConfirmed=false、formalReportPublished=false为本轮预期。没有打开生产浏览器时browserVerified=false,后续由Codex验收。不把报告保存成功说成实物试验、已验证普遍需求或量产已可行。
+
+任一步失败、超时、不确定立即停止,保留原requestId及已取得全部编号/回执;不自动重试、换键、改输入、删除或回滚记录。错误不等于零写入,只读失败不因此重跑任何save或create。交Codex按现存编号独立核对。
+最终回报新候选artifactId/sourceId/opportunityId、新projectId/eventId、项目S3基线artifactId/sourceId、S4 artifactId/sourceId;每步两层调用编号、退出码、核对结果与留存路径。新建3报告+其3正文源+1课堂机会卡+1项目属预期,不改既有S3两版或S1/S2,不建产品/分类,不重导资料、不正式发布。
+'''
+
+put('46-发给运维001-S4课堂项目与评估准备.txt', handoff)
+put('47-发给王刚001-S4课堂项目与桌面评估.txt', execution)
+put('45-S4公开资料入库独立验收结果.md', (DOCS / '45-S4公开资料入库独立验收结果.md').read_bytes())
+put('README.txt', 'S4课堂后续固定模板包。未执行写入。先45,再46准备,再由用户选择课堂测试方向后按47在真实消息执行。\n全部绑定ID字段为null,禁止直接提交模板。机会卡/项目为课堂状态,不是企业审批。没有虚构试吃、评分或成本结果。\n顺序包含项目S3成果→转S4,避免现有无projectRef的旧S3无法满足推进前置。旧报告保持原样。\n')
+manifest = []
+for file in sorted(OUT.iterdir()):
+    if file.is_file():
+        manifest.append(hashlib.sha256(file.read_bytes()).hexdigest() + '  ' + file.name)
+put('FILES.sha256', '\n'.join(manifest) + '\n')
+with zipfile.ZipFile(ZIP, 'x', compression=zipfile.ZIP_DEFLATED, compresslevel=7) as bundle:
+    for file in sorted(OUT.iterdir()):
+        bundle.write(file, file.name)
+with zipfile.ZipFile(ZIP) as bundle:
+    assert bundle.testzip() is None
+    for line in manifest:
+        digest, name = line.split('  ', 1)
+        assert hashlib.sha256(bundle.read(name)).hexdigest() == digest
+for name in ['46-发给运维001-S4课堂项目与评估准备.txt', '47-发给王刚001-S4课堂项目与桌面评估.txt']:
+    with (DOCS / name).open('xb') as stream:
+        stream.write((OUT / name).read_bytes())
+receipt = {'zip': str(ZIP), 'bytes': ZIP.stat().st_size, 'sha256': hashlib.sha256(ZIP.read_bytes()).hexdigest(),
+           'entries': len(manifest) + 1, 'manifestVerified': len(manifest), 'businessApiCalls': 0, 'businessWrites': 0,
+           'saved': False, 'employeeDirectionSelectionConfirmed': False,
+           'templates': {name: {'bytes': (OUT / name).stat().st_size, 'sha256': hashlib.sha256((OUT / name).read_bytes()).hexdigest()} for name in tasks}}
+with (ROOT / 'work/S4-classroom-next-package-receipt-20261103.json').open('x', encoding='utf-8') as stream:
+    json.dump(receipt, stream, ensure_ascii=False, indent=2)
+print(json.dumps(receipt, ensure_ascii=True, indent=2))

+ 288 - 0
scripts/prepare-S5-classroom-next.py

@@ -0,0 +1,288 @@
+"""Prepare S5 templates and handoffs locally. Does not call any business API."""
+from copy import deepcopy
+from pathlib import Path
+import hashlib
+import json
+
+ROOT = Path(__file__).resolve().parents[1]
+DOCS = Path('F:/文档/工作坊当前资料')
+OUT = ROOT / 'work/S5-classroom-next-20261103'
+assert not OUT.exists()
+OUT.mkdir()
+BASE = 'KSF-真实资料-20261102-01'
+S4 = json.loads((ROOT / 'work/S4-classroom-next-20261103/05-project-S4-save.template.json').read_text(encoding='utf-8'))
+accepted = json.loads((ROOT / 'work/live-S4-independent-acceptance-20261103.json').read_text(encoding='utf-8'))
+assert accepted['ok'] and accepted['artifactId'] == 'U3_W7WDliP'
+original = Path('C:/Users/JSHY/.codex/attachments/47f3dfe1-3de6-42a3-8709-ce63f100891c/Pasted text.txt').read_bytes()
+with (ROOT / 'work/S5-submitted-draft-20261103.txt').open('xb') as f:
+    f.write(original)
+
+def put(name, value):
+    with (OUT / name).open('x', encoding='utf-8', newline='\n') as f:
+        f.write(value)
+
+def put_json(name, value):
+    put(name, json.dumps(value, ensure_ascii=False, indent=2) + '\n')
+
+def para(title, text):
+    return {'kind': 'para', 'title': title, 'text': text}
+
+def table(title, cols, rows):
+    return {'kind': 'table', 'title': title, 'data': {'cols': cols, 'rows': rows}}
+
+pending = list(S4['pendingQuestions']) + [
+    '三行分区呈现(净含量/面饼/件数)能否被正确理解,会不会反而更乱。',
+    '信息呈现是否增加购买负担(货架识别、比价、货架停留)。',
+    '包材版面、渠道素材能否执行,改动成本未知。',
+    '“可选搭配”会不会被读成“必须加”,从而反向暗示原份量不足。',
+    '研究中记录为何补主食的方式本身会不会影响行为,需要评估记录负担和观察者效应。',
+]
+inheritance = ['原S4问题'] * 8 + ['承原1、4', '承原4、6', '承原7', '承原8', '承原3、6']
+name = '燃魂份量选择提示与搭配指引(待验证概念)'
+concept = {
+    'name': name,
+    'targetAudience': '拟研究把燃魂拌面用于一次用餐、需要判断一份与当次食量是否适配的人;是假定研究范围,非已证实人群。',
+    'usageScenario': '购买前与制备前判断份量和做法;当次目的可为正餐、加餐或其他,地点、时间、频率等未知。',
+    'specification': {
+        'designScope': '份量与制备信息布局及可选搭配指引;效果待验证,尚未确定新品实物规格。',
+        'packaging': '待核;未选定拟新品包装形态,不把P1图示当目标规格。',
+        'netWeightG': None, 'noodleWeightG': None, 'packCount': None, 'currentSku': None,
+        'informationLayout': ['单份含料净含量单列', '面饼重量单列', '组合件数单列'],
+        'preparation': '拟与后续明确的实际商品包装说明一致;本轮不设煮制时间。',
+        'optionalPairing': '可选搭配与规定步骤分开;不要求加菜,也不承诺搭配解决份量不足。',
+    },
+    'valueProposition': '待验证:份量与做法信息集中呈现,帮助使用者判断是否适合当次用餐;不承诺更饱或购买提升。',
+    'flavor': '待核;燃魂是系列线索,不据此确定具体口味。',
+    'sellingPoints': ['待验证:份量与做法一处看明白。', '待验证:规定步骤与可选搭配清楚分开。'],
+    'productionSteps': [], 'pendingQuestions': pending,
+}
+design_rows = [
+    ['份量信息', 'P1标题117g×5与参数585克算术一致;单袋图目视含料117克、面饼90克。仅是页面/图示观察。', '拟将单份含料净含量、面饼重量、组合件数分三行标示;一包、一组与一次用餐分开。', '当前及拟新品SKU/规格未知,信息理解未测试;重量不直接证明饱腹程度。'],
+    ['煮制信息', 'P1图示6—7分钟;P2作者历史自述自己煮10分钟才软。图示与个体叙述分别保留。', '拟按后续明确商品的实际包装说明呈现制备信息,与可选搭配分开。', '没有购买路径理解测试;P2不证明包装错误或信息位置问题,不设10分钟为新标准。'],
+    ['可选搭配', '两篇份量自述中有额外加食,但原因不全清楚;没有本轮干预或食用结果。', '搭配只作可选信息,不要求加菜;允许不搭配,保留真实用餐结果。', '会不会被理解为必须加菜或暗示原份量不足,额外负担与效果均未知。'],
+    ['增量/双规格', '现有材料不足以支持选定增量幅度或两档规格。', '仅作有条件的备选;明确商品与用途、取得份量问题证据后再比较。', '克数、价格、袋数、配方、包装、产线及可行性未知;不声称燃魂已有或将推出加量版。'],
+]
+spec_rows = [
+    ['方案性质', '信息布局与可选搭配指引的设计提案', '未验证设计效果,不是已定新品'],
+    ['净含量/面饼重量/组合件数', '目标值均待核;结构化数值为null', 'P1标示585克、含料117克、面饼90克及117g×5只作各自页面/图片参考'],
+    ['包装形态/当前SKU/具体口味', '均待核', '页面对象不等于已取得样品,旧帖/P1/P2不合并SKU'],
+    ['信息布局', '拟三行分别呈现净含量、面饼重量与件数', '是拟定设计,尚未验证理解效果或版面可执行性'],
+    ['制备说明', '待明确实际商品后与其包装说明一致', '不把归档6—7分钟或作者10分钟设为拟新品标准'],
+    ['配方/配料', '未取得;formulaItems=[]', '不造materialId、比例、用量或供应商,不以空数组认定无需配料'],
+    ['生产工艺', '未取得;productionSteps=[]', '不造processId;用户使用、研究和待核步骤不充作生产工序'],
+    ['成本/价格/合规', '资料未取得,保持未知', '不填0、不作正向可行性评分,不声称低成本、合规通过或可量产'],
+]
+quote_rows = [[e['claim'], e['quotes'][0]['text'], e['sourceId']] for e in S4['report']['evidence'].values()]
+validation_rows = [
+    ['信息表达', '未来用明确标为概念稿的说明布局做理解观察;目标、对象、记录方式及规则事前确定。', '出现误读、阅读或购买负担时调整版式与文案。', '资源不足或预先规则触发时暂停该设计;无差异不等于已证明无效。'],
+    ['份量适配', '有明确商品与实际食用观察后,分别记录用途、习惯、提前加主食与吃后补食的原因。', '习惯与补偿可并存;动机混杂时继续区分,不因习惯存在便排除份量问题。', '缺对象/行为证据则不下结论;不凭未定义的“多数”判定整个份量假设无效。'],
+    ['增量/双规格', '份量问题证据足以提出比较后,再事先确定规格、资源、方法和判断规则。', '证据或执行条件不足时调整问题,保留其他解释。', '本轮不实施;暂不推进不等于已经证明方案没有需求。'],
+    ['企业可行性', '取得配方、工艺、成本、包材、渠道及相应合规依据后再评。', '依据冲突或条件不满足时调整方案。', '资料未取得则判断为未知,暂停可行性判断,不认定不可行或可量产。'],
+]
+chapters = [
+    {'id': 'scope', 'title': '方案范围与人群场景', 'stage': 'S5', 'sections': [
+        para('方案摘要', '把一份面的份量、份数、制备方式和可选搭配集中呈现,供购买前与制备前判断是否适合当次用餐。这是目前可展开比较的设计提案,不是已验证需求;不承诺仅靠文案解决吃不饱。'),
+        para('拟研究人群与场景', concept['targetAudience'] + concept['usageScenario'] + '年龄、购买力和购买身份未知,不补造学生夜宵或加班白领等事实画像。'),
+        para('拟定使用路径', '购买前判断份量与做法→按后续明确商品的实际包装制备→按本次目的选择是否搭配并食用。这是设计路径,没有真实消费者执行结果。'),
+        para('研究记录单列', '若后续开展研究,可另行记录为何补主食;记录方法、时机和负担需审阅。它不作为产品使用的必需步骤,也不默认能无干扰地观察行为。'),
+    ]},
+    {'id': 'design', 'title': '设计内容与候选卖点', 'stage': 'S5', 'sections': [
+        table('设计对照', ['设计项', '已有依据及边界', '拟定设计', '仍需验证'], design_rows),
+        para('候选文案', '待验证文案:“份量与做法,一处看明白。一包多少、一组几包、怎么煮,都写在一处。”这是待制作和验证的设计,不是现行包装或已验证卖点。'),
+        para('提出设计的理由', 'P1提供页面参数与图示标示,P2提供历史个体制备叙述;两者均未证明“信息分散”或购买路径不清。集中呈现与可选搭配分区是设计假设,理解、购买判断、购买行为与饱腹体验分别验证。设计、包材与渠道执行成本未知。'),
+        para('增量与承诺边界', '增量/双规格仅为条件分支,不给克数、价格或配方;不声称燃魂已有加量版。候选文案不作管饱、健康营养、成本最优或固定分钟必熟的承诺。'),
+    ]},
+    {'id': 'spec', 'title': '规格与执行条件', 'stage': 'S5', 'sections': [
+        table('设计、观察与未知', ['项目', '本方案状态', '依据或约束'], spec_rows),
+        para('结构化口径', '本轮方案结构可记录待核规格对象、候选卖点和13项未决问题。空配料与工艺数组表示未取得依据,不表示产品不需要原料或工艺。方案保存与阶段报告分别用专用命令,不创建产品、SKU、原料或工艺实体。'),
+    ]},
+    {'id': 'evidence', 'title': '证据与适用边界', 'stage': 'S5', 'sections': [
+        table('主引文', ['支持范围', '逐字引文', 'sourceId'], quote_rows),
+        para('历史个案与图示分开', 'cq-03/cq-04为2023年两个显示账号的历史自述。cq-04同文肯定味道;加蛋肠原因未知,cq-03加挂面的原因明确但其他配菜动机未知。P1是公开高清图片页观察,P2为2023年个人叙述;都未核购买身份,不拼成同SKU。来源可支持提出设计,不能证明设计有效或市场普遍需求。'),
+    ]},
+    {'id': 'validation', 'title': '验证路径与未决问题', 'stage': 'S5', 'sections': [
+        table('继续、调整与暂停', ['维度', '何时继续', '何时调整', '何时暂停或不下结论'], validation_rows),
+        table('待补问题', ['序号', '待核实问题', '承接关系'], [[str(i + 1), q, inheritance[i]] for i, q in enumerate(pending)]),
+        para('阶段结论', '已形成可审阅的信息提示与搭配指引概念;设计效果、当前需求、实物、市场和量产验证未完成。计划保存后项目保持S5 / in_progress;本轮执行不包含completed、员工确认、企业审批或正式发布。'),
+    ]},
+]
+report = {
+    'subtitle': '份量与做法信息呈现的待验证概念,规格与效果待核',
+    'executive': [
+        '基于已验收S4公开资料桌面评估形成设计概念,非试吃、市场或量产结果。',
+        '主方案是份量选择提示与可选搭配指引;三行信息布局是设计假设,不称已经发现信息分散。',
+        '增量或双规格仅为有条件的备选;规格、口味、配方、工艺与成本尚未确定。',
+        '沿用4来源/4主引文;保留S4原8项缺口并增加5项设计问题,共13项。',
+    ],
+    'method': '依据S4固定内容与既有来源验收进行桌面设计和证据边界审阅;本轮没有新采集、称量、试吃、招募、问卷、A/B测试或市场比例估计。候选文案与信息布局为拟定设计。',
+    'sources': S4['report']['sources'],
+    'chapters': chapters, 'evidence': deepcopy(S4['report']['evidence']),
+}
+title = '燃魂份量选择提示与搭配指引|S5桌面方案 第1版'
+raw_parts = [title, *report['executive']]
+for chapter in chapters:
+    raw_parts.append(chapter['title'])
+    for section in chapter['sections']:
+        raw_parts.append(section['title'])
+        if section['kind'] == 'para':
+            raw_parts.append(section['text'])
+        else:
+            raw_parts.append(' | '.join(section['data']['cols']))
+            raw_parts.extend(' | '.join(row) for row in section['data']['rows'])
+raw_text = '\n\n'.join(raw_parts)
+tasks = {
+    '01-project-S5-progress.template.json': {'sessionId': None, 'projectId': None, 'toStage': 'S5', 'state': 'in_progress',
+        'note': 'S4公开资料桌面评估U3_W7WDliP已保存并独立验收;进入S5形成待验证的信息提示与搭配指引概念,不代表实物、市场或量产验证完成。'},
+    '02-concept-save.template.json': {'sessionId': None, 'projectId': None, 'version': 1, 'concept': concept, 'formulaItems': []},
+    '03-project-S5-save.template.json': {'sessionId': None, 'projectId': None, 'taskKey': BASE + ':S5', 'version': 1, 'stageCode': 'S5',
+        'title': title, 'artifactType': 'concept', 'category': '拌面', 'rawText': raw_text, 'sourceIds': deepcopy(S4['sourceIds']),
+        'pendingQuestions': pending, 'report': report},
+}
+for filename, task in tasks.items():
+    put_json(filename, task)
+request_specs = [
+    ('project-status', BASE + ':portion-fit-project:S5:precheck:v1', '00-project-status.json'),
+    ('project-progress', BASE + ':portion-fit-project:to-S5:v1', '01-project-S5-progress.json'),
+    ('project-status', BASE + ':portion-fit-project:read-S5:v1', '00-project-status.json'),
+    ('concept-save', BASE + ':portion-fit-project:concept:save:v1', '02-concept-save.json'),
+    ('project-status', BASE + ':portion-fit-project:concept:read:v1', '00-project-status.json'),
+    ('workshop-save', BASE + ':S5:save:v1', '03-project-S5-save.json'),
+    ('workshop-read', BASE + ':S5:read:v1', '03-project-S5.read.json'),
+    ('project-status', BASE + ':portion-fit-project:S5:read-final:v1', '00-project-status.json'),
+]
+runbook = {'sessionId': 'btrFqkz0yw', 'projectId': 'S_CikxZeBs', 'opportunityId': 'QE9hjtCj8E',
+    'priorS3ArtifactId': 'XF4h-Z7l5o', 'priorS4ArtifactId': 'U3_W7WDliP', 'taskKey': BASE + ':S5',
+    'steps': [{'step': i + 1, 'command': cmd, 'requestId': req, 'inputFile': inp} for i, (cmd, req, inp) in enumerate(request_specs)],
+    'expectedFinal': {'stageCode': 'S5', 'progressState': 'in_progress', 'projectArtifactCount': 3,
+        'projectEventCount': 7, 'conceptVersion': 1, 'formulaItemCount': 0, 'pendingQuestions': 13,
+        'newCommittedWrites': 3, 'formalReportPublished': False, 'employeeConfirmed': False, 'enterpriseApproval': False}}
+put_json('runbook.json', runbook)
+
+markdown = ['# ' + title, '', '待验证概念;未执行生产保存,系统阶段仍为S4 / in_progress。', '']
+markdown += [p + '\n' for p in report['executive']]
+for chapter in chapters:
+    markdown += ['## ' + chapter['title'], '']
+    for section in chapter['sections']:
+        markdown += ['### ' + section['title'], '']
+        if section['kind'] == 'para':
+            markdown += [section['text'], '']
+        else:
+            data = section['data']
+            def cell(value):
+                return str(value).replace('|', '\\|').replace('\n', '<br>')
+            markdown += ['| ' + ' | '.join(data['cols']) + ' |', '| ' + ' | '.join(['---'] * len(data['cols'])) + ' |']
+            markdown += ['| ' + ' | '.join(cell(v) for v in row) + ' |' for row in data['rows']]
+            markdown += ['']
+put('S5桌面方案第1版-审阅稿.md', '\n'.join(markdown) + '\n')
+
+review = '''# S5桌面方案审阅与定稿
+
+2026年11月3日。王刚001提交的文字草稿可进入固定材料准备,无需再退回重复改稿。Codex已直接校正以下内容,定稿正文见同包《S5桌面方案第1版-审阅稿.md》,具体持久化输入以三份固定模板为准。
+
+1. 删去“P1信息分散”这一事实断言。现有参数与图示核查没有验证信息位置或购买路径,集中呈现是设计提案。
+2. P2只支持作者历史制备叙述,不能证明包装错误、说明位置问题或设计有效;“无任何证据”改为“现有材料不足以支持选定增量幅度/两档规格”,避免抹掉份量历史线索。
+3. 不用未定义的“多数为习惯”判断整个份量问题停止。习惯与补偿可能并存;资料缺失时暂停相应判断,不写证明无效或不可行。
+4. 将研究中的“记录为何补主食”从产品使用必经步骤中移出,另列记录负担与观察者效应待验证。
+5. “四份来源原站核查与购买身份核验沿用50号”更正为“原站核查沿用既有验收;购买身份仍未独立核实”。作者、购买与普遍需求不冒称已核。
+
+S4原8条pendingQuestions逐字保留;新增5条设计问题,共13条,方案输入与报告及可见表完全对应。四段主引文直接从S4固定报告取值,P1使用真实换行,不把Markdown的<br>写进quote。所有下划线编号保留。
+
+未来concept仅记录待验证的信息提示与搭配指引概念。规格数值null,formulaItems=[]、productionSteps=[];没有创建原料/工艺或把研究步骤当生产步骤。pendingQuestions按现网处理写入项目meta.pendingQuestions和保存响应,不要求KfConcept上有同名字段。
+
+本轮Codex只做本地审阅准备及另行留证的生产只读预检,没有运行project-progress/concept-save/workshop-save。拟执行后为S5 / in_progress、1个concept v1、项目3份报告及7条事件,不自动completed、员工确认或正式发布。概念、报告和正文来源是三个不同编号;方案并未通过conceptRef直接写进KfArtifact,核对方式为同项目、版本、字段及事件对应,不造不存在的Pointer。
+
+王刚001两条业务只读及身份登记调用编号、远端目录8文件/权限/退出码依据提交回报,Codex没有读取该远端文件系统。本轮未将这些回报升级为Codex独立核实。归档原稿保持原字节,系统对象精确编号按runbook.json,不按口头省略下划线的文本拼命令。
+
+本包供53号运维落地预检,通过后按54号在王刚001真实消息中执行。沿用用户已选方向,不重新问选项、不要求先提供实物,不重导资料、不建第二套卡或项目。执行前状态或合同有新差异则停核,不能用本地模拟代替生产回读。
+'''
+put('52-S5桌面方案审阅与定稿.md', review)
+
+hash_lines = '\n'.join(f"{n}:{len((OUT/n).read_bytes())}B,sha256={hashlib.sha256((OUT/n).read_bytes()).hexdigest()}" for n in tasks)
+ops = f'''运维001,请按本包准备S5固定模板;本轮只落地与只读预检,不代跑业务。
+
+先读52号审阅裁定及S5审阅稿。沿用用户选定的燃魂份量与用餐目的适配,方案为“份量选择提示与搭配指引”(待验证概念),不是已验证需求或量产新品。当前会话btrFqkz0yw、项目S_CikxZeBs,S4/in_progress;S4成果U3_W7WDliP已验收,不重做S4、不再问方向、不要求用户先交实物。
+
+将三份template原样落地/opt/data/ksf-executor-20261103/S5-classroom-next-01/,root:root 0644,hermes实读。三份均sessionId=null/projectId=null,不可直接提交;本轮不生成绑定ID的可执行输入。
+
+固定指纹:
+{hash_lines}
+
+按FILES.sha256核包,按现网专用命令合同核project-progress / concept-save / workshop-save / project-status / workshop-read。合法转阶段命令是project-progress,不能写project-transition;artifactType=concept,reportType按workshop_auxiliary、artifactSubtype=classroom_task核,不从通用导入表字段推定命令形状。发现差异只报不改Skill/Function/schema/wrapper或放宽校验。
+
+核同一当前真实会话/员工/Agent归属,项目仍S4/in_progress,机会QE9hjtCj8E与项目互链;S3基线XF4h-Z7l5o、S4评估U3_W7WDliP恰两份项目成果、四条事件、无concept。只读核四来源/证据对及sourceRef、原文quote、productRef=null:m1OQx9aynA/_9mUml0wmh,QdeMcyTTjX/2bFGP7axMo,PjORcvaRHn/xHGV_mRWcG,5S_Y4IgT5q/CeAEIwymTa,不重导或重新采集。
+
+查本项目KfConcept(包括currentConceptRef未挂载的既有版本)与KSF-真实资料-20261102-01:S5系列,无v1或其它版本;查runbook.json八个requestId在KsfOperation的成功写入审计、相关对象与本地回执。读取requestId不保证入KsfOperation,不能据该表声称读命令从未运行。已有对象或回执即停报编号,不换键、删记录或升级版本绕开。
+
+核目标/opt/data/ksf-workshop-private/S5-classroom-next-01/及54号所有将生成的输入、stdout/stderr/退出码目标均不存在、无在途;目录仅不存在才新建hermes:hermes0700且确认为空。保留S5-live-00/、S1—S4各版、原第08失败与恢复回执。不要复用或覆盖旧目录。
+
+静态自检:pendingQuestions方案与报告恰13条且逐字相同;前8条逐字等于S4;可见待补表13行逐项对应;4来源/4主引文与S4完全相同;specification未知数值为null;formulaItems=[]、productionSteps=[];无opportunity对象,无新project-create。specification是非空JSON对象,不为满足字段临时填数字。
+
+本轮不执行project-progress/concept-save/workshop-save,不更新任何旧业务记录或前端,不正式发布。回报模板字节/完整哈希、合同、身份归属、来源、版本/回执/在途及目标文件检查;通过后由用户转交54号王刚001真实消息执行。按原业务键一次执行,任一步失败即停。包内preflight与隔离模拟只证明本地准备,不代替现网结果。
+'''
+put('53-发给运维001-S5方案与报告准备.txt', ops)
+
+steps_text = '\n'.join(f"{i+1:02d}. {cmd};input={inp};requestId={req}" for i, (cmd, req, inp) in enumerate(request_specs))
+actor = f'''王刚001:本单用于已审阅的S5方案及课堂报告保存。用户此前已选“燃魂份量与用餐目的适配”,本条转交表示继续既有课堂测试,不再问选择。先确认53号落地预检通过,52号校正已纳入固定模板。沿用会话btrFqkz0yw、项目S_CikxZeBs、机会QE9hjtCj8E;不新建会话、卡或项目。
+
+一、当前消息身份与输入
+
+无参数运行ksfregistercurrent_employee(可信运行时正式工具名以现有接入为准),核完整agentProfileKey与员工/发送者/会话及S4归属一致。不得用旧截断键、姓名或复制身份JSON代替本条真实核验;不一致即停。跨日不自动新会话。
+
+三模板位于/opt/data/ksf-executor-20261103/S5-classroom-next-01/;完整哈希如下:
+{hash_lines}
+
+执行留存/opt/data/ksf-workshop-private/S5-classroom-next-01/必须为53号已确认的空目录0700。先核目标不存在及无历史回执/在途;每份绑定输入都从模板读进内存,只填sessionId=btrFqkz0yw、projectId=S_CikxZeBs,逐字段差异恰这两处,O_EXCL独占新建0600。所有读输入、每步stdout.json/stderr.log/exitcode也独占新建0600;已有即停查,不覆盖。复用本单只读输入是规定操作,不重建同路径。
+
+二、八步固定顺序与requestId
+
+{steps_text}
+
+第01步:独占新建00-project-status.json,仅含{{"sessionId":"btrFqkz0yw","projectId":"S_CikxZeBs"}};核两层ok+EXIT0、项目S4/in_progress/live/workshop、同归属,S3基线XF4h-Z7l5o与S4报告U3_W7WDliP恰两份、事件四条、concept=null、formulaItems=[]。任何变化先停,不套旧状态继续。
+
+第02步:从01-project-S5-progress.template.json绑定两ID生成01-project-S5-progress.json,执行一次project-progress;应S5/in_progress、projectId不变、enterpriseApproval=false,取eventId。
+第03步:复用00-project-status.json;应S5/in_progress、成果仍两份、事件五条,最后一条stage_changed,无concept。
+
+第04步:从02-concept-save.template.json绑定两ID生成02-concept-save.json,执行一次concept-save;取conceptId/version=1/projectId/eventId,formulaItemIds=[]、pendingQuestions13条逐字等模板、enterpriseApproval=false。
+第05步:复用00-project-status.json。核project.currentConceptRef→KfConcept/新conceptId;concept.objectId、projectRef、version1、dataMode=live、recordState=active;name/targetAudience/usageScenario/specification/valueProposition/flavor/sellingPoints/productionSteps逐项等模板,specification含null也做深比。formulaItems=[];项目meta.pendingQuestions13条逐字等模板;事件六条,末条concept_saved;项目成果仍两份。pendingQuestions不要求在KfConcept有同名字段,不能因此重存或补写。previousVersionRef可缺/null,不声称已有版本链。
+
+第06步:从03-project-S5-save.template.json绑定两ID生成03-project-S5-save.json,执行一次workshop-save;取新artifactId/sourceId,sessionId及projectId沿用,version1/S5,opportunityId=null。方案conceptId与报告artifactId是不同对象;本报告输入没有conceptRef,不臆造直接Pointer。
+第07步:根据第06实际返回的artifactId,独占新建03-project-S5.read.json,仅含{{"className":"KfArtifact","objectId":"<新artifactId>"}}。精读核下节所有字段及可见表。
+第08步:复用00-project-status.json;核项目S5/in_progress,成果恰3份(旧S3、旧S4、本次S5),concept仍第04同一v1,formulaItems=[],meta.pendingQuestions13条;事件恰七条按序:原project_created、task_submitted、stage_changed、task_submitted,再本次stage_changed、concept_saved、task_submitted。最后两条分别为概念保存与S5报告提交。本轮不执行completed,不自行再转阶段。
+
+三、命令格式与留存
+
+写:node /opt/data/skills/ksf-workshop-runtime/scripts/ksf-secure.mjs <project-progress或concept-save或workshop-save> --agent-profile <本条已核完整agentProfileKey> --input /opt/data/ksf-workshop-private/S5-classroom-next-01/<本步输入名> --request-id <本步完整requestId>
+读:node /opt/data/skills/ksf-workshop-runtime/scripts/ksf-secure.mjs <project-status或workshop-read> --input /opt/data/ksf-workshop-private/S5-classroom-next-01/<本步输入名> --request-id <本步完整requestId>
+
+八步stdout/stderr/退出码按01-precheck、02-to-S5、03-status-S5、04-concept-save、05-concept-status、06-S5-save、07-S5-read、08-final-status分别留存;保存结果编号另做独占JSON记录。每步核外层ok、entryResult.ok、EXIT=0,stderr如有内容逐条解释,不以某个空字段直接宣布失败或通过。
+
+四、S5报告精确回读
+
+核artifactType=concept、reportType=workshop_auxiliary、artifactSubtype=classroom_task、stageCode=S5、version1、schemaVersion=ksf.v0.7、dataMode=live、dashboardState=workshop、reportSeriesKey={BASE}:S5。ownerSession/sessionRef→btrFqkz0yw,projectId/projectRef→S_CikxZeBs,Agent归属与既有一致,sourceRef→第06返回新正文sourceId,tags含拌面与课堂过程成果。
+
+rawText逐字等模板;sourceIds恰m1OQx9aynA/QdeMcyTTjX/PjORcvaRHn/5S_Y4IgT5q;evidenceCount4,四段主引文含P1换行逐字与report.evidence全块一致。report.chapters/method/sources/subtitle逐项等模板,executive仅在最前预期增加“课堂过程成果,未经员工正式确认;待验证项不作为市场结论。”,report.title/generatedAt为服务新增。
+
+meta.workshop.pendingQuestions恰13条且逐字等方案输入与项目meta.pendingQuestions;report中待补问题表13行、原8条与新增5条承接关系全等;其余设计对照/规格/主引文/验证路径表均全块比对。不要只核文字rawText而漏掉可见表。
+
+boardVisible只在保存响应核为true,不要求回读存在。正式发布/员工确认在保存响应为false,dashboardPublishedAt为null;字段缺失不等于“已正式发布”。enterpriseApproval=false,未开生产浏览器则browserVerified=false,后续Codex按新编号验收;本地预览不能替代生产浏览器。
+
+五、停止与边界
+
+任一步失败、超时或不确定立即停,保留原requestId、输入、所有已返回编号与完整回执;不自动重试、换键、删记录或回滚。错误不等于零写入;只读失败不因此重做任何save/progress。交Codex按编号核对后决定恢复方式。
+
+预期新增仅1个KfConcept v1、1份KfArtifact及其正文KfSource、3条事件和3条写入审计;同一项目达到S5/in_progress。方案pendingQuestions更新项目meta,不覆盖旧报告的8条缺口。无新机会卡/项目/产品/SKU/配方原料/生产工艺,不重导来源,不改S1—S4、Skill/Function/schema/wrapper/前端,不采购、招募或试吃,不代员工确认或正式发布。当前实物、市场和量产验证仍未发生。
+
+最终回报八步requestId、两层invocationId、EXIT及stderr,conceptId/artifactId/sourceId/eventId、逐项精确核对结果、项目阶段/成果/事件、实际留存清单和权限。不要把课堂方案存入库说成当前产品已定型或需求已证实。保留S5-live-00/及全部旧回执。
+'''
+put('54-发给王刚001-S5方案与课堂报告保存.txt', actor)
+put('README.txt', 'S5固定材料包:先52审阅,再53运维只读预检,通过后54由王刚001真实消息执行。\n三模板sessionId/projectId为null,不可直接提交。顺序为S4前检→project-progress转S5→concept-save并核→S5报告保存并核→最终状态。\n只记录待验证概念,配方工艺缺资料时数组为空、数值待核。不自动completed或正式发布。不含凭据、浏览器档案或完整私聊。\n')
+put_json('preflight.json', {'ok': True, 'at': '2026-11-03', 'localStructureOnly': True, 'packagePreparationBusinessApiCalls': 0,
+    'productionBusinessWrites': 0, 'saved': False, 'browserVerified': False,
+    'sourceIds': S4['sourceIds'], 'evidenceCount': 4, 'pendingQuestionCount': 13,
+    'originalS4PendingCount': 8, 'newDesignPendingCount': 5, 'rawTextCodepoints': len(raw_text),
+    'chapters': len(chapters), 'tables': [{'title': s['title'], 'cols': len(s['data']['cols']), 'rows': len(s['data']['rows'])}
+        for c in chapters for s in c['sections'] if s['kind'] == 'table'],
+    'inputDraftSha256': hashlib.sha256(original).hexdigest(),
+    'scopeNote': 'Separate live read-only preflight and isolated simulation are recorded outside this initial local-structure preparation.'})
+print(json.dumps({'ok': True, 'prepared': str(OUT), 'templates': len(tasks), 'rawTextCodepoints': len(raw_text),
+    'pendingQuestions': len(pending), 'businessApiCalls': 0, 'productionBusinessWrites': 0}, ensure_ascii=True))

+ 143 - 0
scripts/prepare-S5-completion.py

@@ -0,0 +1,143 @@
+"""Prepare classroom closeout handoff locally; no API or production writes."""
+from pathlib import Path
+import hashlib
+import json
+
+ROOT = Path(__file__).resolve().parents[1]
+DOCS = Path('F:/文档/工作坊当前资料')
+OUT = ROOT / 'work/S5-classroom-completion-20261103'
+BASE = 'KSF-真实资料-20261102-01'
+assert not OUT.exists()
+OUT.mkdir()
+accepted = json.loads((ROOT / 'work/live-S5-independent-acceptance-20261103.json').read_text(encoding='utf-8'))
+assert accepted['contentAndPageAccepted'] and accepted['browserVerified']
+assert accepted['executionConformsToFixedRequestIds'] is False
+report = json.loads((ROOT / 'work/S5-classroom-next-20261103/03-project-S5-save.template.json').read_text(encoding='utf-8'))
+assert len(report['pendingQuestions']) == 13
+
+def put(name, text):
+    with (OUT / name).open('x', encoding='utf-8', newline='\n') as stream:
+        stream.write(text)
+
+def put_json(name, obj):
+    put(name, json.dumps(obj, ensure_ascii=False, indent=2) + '\n')
+
+note = '课堂任务完成:S1至S5公开资料流程成果及S5方案/报告已保存并独立验收;项目沿用S_CikxZeBs,概念QO0uo8nisk,报告tLkz2MAoIw。13项研究/设计未决问题全部保留;完成不代表普遍需求已证实、实物/市场/量产验证、员工确认、正式发布或企业批准。S5报告实际保存requestId与54号存在偏差,已在55号留档,不补跑保存。'
+template = {'sessionId': None, 'projectId': None, 'toStage': 'S5', 'state': 'completed', 'note': note}
+assert len(note) <= 500
+put_json('01-project-complete.template.json', template)
+template_bytes = (OUT / '01-project-complete.template.json').read_bytes()
+template_hash = hashlib.sha256(template_bytes).hexdigest()
+
+steps = [
+    {'step': 1, 'command': 'project-status', 'requestId': BASE + ':portion-fit-project:completion:precheck:v1', 'inputFile': '00-project-status.json'},
+    {'step': 2, 'command': 'project-progress', 'requestId': BASE + ':portion-fit-project:complete:v1', 'inputFile': '01-project-complete.json'},
+    {'step': 3, 'command': 'project-status', 'requestId': BASE + ':portion-fit-project:completion:read-final:v1', 'inputFile': '00-project-status.json'},
+]
+runbook = {
+    'sessionId': 'btrFqkz0yw', 'projectId': 'S_CikxZeBs', 'opportunityId': 'QE9hjtCj8E',
+    'conceptId': 'QO0uo8nisk', 's5ArtifactId': 'tLkz2MAoIw', 's5OutputSourceId': '5zsPtYFL1u',
+    'projectArtifactIds': ['XF4h-Z7l5o', 'U3_W7WDliP', 'tLkz2MAoIw'],
+    'existingEventIds': ['GGDTcg5YTy', 'RkkTTE_xeb', 'hOFghbcMsY', 'VlGFu_jv4b', '0ya0a03U9G', 'qKnPnVkZA8', 'iUs3JwZ9Kf'],
+    'pendingQuestions': report['pendingQuestions'],
+    'steps': steps,
+    'remoteTemplateDirectory': '/opt/data/ksf-executor-20261103/S5-classroom-completion-01',
+    'remoteReceiptDirectory': '/opt/data/ksf-workshop-private/S5-classroom-completion-01',
+    'templateSha256': template_hash,
+    'expectedBefore': {'stageCode': 'S5', 'progressState': 'in_progress', 'artifactCount': 3, 'eventCount': 7, 'conceptCount': 1, 'pendingQuestionCount': 13},
+    'expectedAfter': {'stageCode': 'S5', 'progressState': 'completed', 'artifactCount': 3, 'eventCount': 8, 'lastEventType': 'completed', 'conceptCount': 1, 'pendingQuestionCount': 13, 'enterpriseApproval': False},
+    'retainedS5SaveRequestIdDeviation': accepted['requestIdDeviation'],
+    'prohibitedOperations': ['workshop-save', 'concept-save', 'project-create', 'workshop-import', 'report-submit'],
+}
+put_json('runbook.json', runbook)
+
+review = '''# 课堂总结与完成条件
+
+2026年11月3日。当前生产状态仍为S5 / in_progress。本轮准备课堂收尾,不把软件课堂任务完成扩写为研究、企业审批或上市完成。
+
+| 阶段 | 已留存成果 | 本轮判断 |
+|---|---|---|
+| S1 品类资料 | G8IrkkDG7X,第2版 | 资料小结已保存、验收;市场数字和在售边界沿用原结论 |
+| S2 用户线索 | b7NOby5ys0,第1版 | 4篇历史个体自述,不证明普遍需求 |
+| S3 假设与选题 | ZW1FB5zeHr;候选V2z4ox7Ehx;项目基线XF4h-Z7l5o | 用户选择燃魂份量与用餐目的适配;机会QE9hjtCj8E、项目S_CikxZeBs已互链 |
+| S4 桌面评估 | U3_W7WDliP,第1版 | 完成公开资料核对和缺口评估;没有实物/市场验证结果 |
+| S5 桌面方案 | 概念QO0uo8nisk及报告tLkz2MAoIw,第1版 | 内容与电脑/手机生产页面已通过55号验收;未知规格、空配方/工艺及13未决项保留 |
+
+完成条件:同一项目处于S5;当前概念存在且正确关联;项目S3/S4/S5三份成果齐备;原七事件无重复、无completed;13问题逐字保留;执行键偏差如实留档。上述条件满足时,现网project-progress允许在S5将state记为completed。实际写入须由王刚001在可信真实消息身份下执行58号,运维只负责57号落地与只读预检。准备或模拟通过不代表生产已完成。
+
+本轮不新建总结报告、不另存S5版本,不增加来源、概念、机会卡或项目;只拟新增一条completed事件和一条成功写入审计,并更新既有项目/会话进度。完成事件的note记录课堂边界和历史偏差。
+
+## 需要保留的执行问题
+
+S5报告实际保存requestId为KSF-真实资料-20261102-01:portion-fit-project:S5:save:v1,54号规定为KSF-真实资料-20261102-01:S5:save:v1。55号已经独立定位成功审计;不得用规定键补跑。下次交接可附远端实际第06步原命令/回执说明原因;不要为查明原因重新执行命令。这个未解释的偏差继续作为流程问题保留,不将课堂收尾写成“全程完全符合执行单”。
+
+S4第08步历史本地USAGE失败和后续原键恢复回执仍保留。S5本轮执行计数为3个写命令、5个读命令;报告为5章15节5张表。手机窄列表格、商品页人数未知标签及长出处横滚问题未修复。
+
+## 研究和设计未决项(逐字沿用)
+
+'''
+review += '\n'.join(f'{i}. {q}' for i, q in enumerate(report['pendingQuestions'], 1))
+review += '''
+
+研究后续可分两路:信息理解研究可先制作明确标为概念稿的布局,再预先确定对象、方法和判据;实物份量研究在实际研究时明确商品、样品、资源与操作条件。这里没有执行研究、采购、招募或试吃。课堂completed不清空问题,不认定未决项已经解决。
+
+正式员工确认与报告发布沿用各自真人确认流程,不能用课堂completed替代;企业批准和研发量产依据仍未知。当前研究资料缺口不阻塞已经具备成果的课堂收尾,也不要求用户先提供实物才能测试软件流程。
+'''
+put('56-课堂总结与完成条件.md', review)
+
+ops = f'''运维001:请准备课堂收尾,本轮仅落地与只读预检,不代跑project-progress。
+
+先读56号与55号独立验收;沿用会话btrFqkz0yw、项目S_CikxZeBs、机会QE9hjtCj8E、概念QO0uo8nisk及报告tLkz2MAoIw。课堂完成不等于需求/实物/市场/量产验证、员工确认、正式发布或企业批准。
+
+原样落地01-project-complete.template.json至/opt/data/ksf-executor-20261103/S5-classroom-completion-01/,root:root 0644、hermes实读。固定模板{len(template_bytes)}B,sha256={template_hash}。sessionId/projectId均为null,toStage=S5、state=completed,note逐字保留;不生成绑定真实ID的输入,不修改模板或旧资料。先核FILES.sha256,再按现网专用命令合同核project-progress和project-status,不使用project-transition。
+
+只读预检:项目仍S5/in_progress/live/status=workshop、ownerSession/sessionRef与当前王刚001归属一致;机会互链正确;currentConceptRef=QO0uo8nisk v1;成果恰XF4h-Z7l5o/U3_W7WDliP/tLkz2MAoIw;事件恰原7条且无completed;项目meta.pendingQuestions13条逐字等runbook;concept规格未知数值保留null、productionSteps及响应顶层formulaItems为空。旧S5报告仍v1,正文/章节/四引文/待补等原固定模板;未确认、未正式发布。
+
+查runbook三个requestId的本地回执/在途及KsfOperation成功写入审计;只有一个未来写键{steps[1]['requestId']}。读键不保证入审计表,不以查不到推断从未执行读命令。项目已completed、对象/事件不符、已有写入或结果不确定即停报编号,不重跑、不换键、不升级版本。
+
+新留存/opt/data/ksf-workshop-private/S5-classroom-completion-01/仅不存在才建hermes:hermes0700并确认为空。核58号两个输入、三组stdout/stderr/exitcode及结果编号文件均不存在,无在途。保留S5-classroom-next-01/及全部旧失败/恢复回执、模板/包;不复用旧目录。
+
+历史S5保存键差异已在55/56号保留,不能拿本轮完成键补存报告。可只读取得实际第06步原命令/回执用于解释偏差,取不到就注明未取得,不重新执行任何旧命令。
+
+本轮不改Skill/Function/schema/wrapper/前端、不重启,不执行project-progress/concept-save/workshop-save/import/create/正式发布,不清空13问题。回报模板指纹、合同、归属、成果/概念/事件、13问题、历史/在途与目标文件检查。通过后转交58号给王刚001在真实消息中执行,运维不代执行。
+'''
+put('57-发给运维001-课堂完成准备.txt', ops)
+steps_text = '\n'.join(f"{s['step']:02d}. {s['command']};input={s['inputFile']};requestId={s['requestId']}" for s in steps)
+actor = f'''王刚001:本单用于既有项目课堂收尾。用户已要求继续既有工作坊流程,不再问方向或重起草。先确认57号落地预检通过,读56号课堂总结;只将本项目记为S5/completed,13项研究未决问题不变。完成不等于企业批准、员工正式确认、正式发布或实物/市场/量产验证。
+
+一、真实身份与固定输入
+
+无参数运行ksfregistercurrent_employee(以现有可信接入正式工具名为准),核本条完整agentProfileKey、员工/发送者及会话btrFqkz0yw归属,与项目S_CikxZeBs、概念QO0uo8nisk、报告tLkz2MAoIw的既有归属一致;跨日不自动新建会话。不能拿旧键或姓名冒充本条核验,不一致即停。
+
+模板/opt/data/ksf-executor-20261103/S5-classroom-completion-01/01-project-complete.template.json:{len(template_bytes)}B,sha256={template_hash}。
+留存/opt/data/ksf-workshop-private/S5-classroom-completion-01/应为57号确认的空0700目录。确认目标无历史回执、无在途且不存在;所有新输入和每步stdout/stderr/退出码用O_EXCL独占写0600,已有即停,不覆盖旧文件。
+
+二、三步固定顺序(1写、2读)
+
+{steps_text}
+
+第01步:独占新建00-project-status.json,仅含{{"sessionId":"btrFqkz0yw","projectId":"S_CikxZeBs"}}。project-status读后核S5/in_progress/live/status=workshop,ownerSession/sessionRef及Agent归属一致;三份成果恰XF4h-Z7l5o、U3_W7WDliP、tLkz2MAoIw;concept=QO0uo8nisk v1且currentConceptRef正确;formulaItems=[];meta.pendingQuestions13条逐字等runbook;事件原七条,完整ID按runbook.existingEventIds且顺序1—7,无completed。状态已有变化即停,不套旧状态执行。
+
+第02步:读模板入内存,仅填sessionId=btrFqkz0yw、projectId=S_CikxZeBs,逐字段差异恰这两项,其他整份深度相等。O_EXCL新建01-project-complete.json。执行前从runbook第02步原样读取requestId,不自行拼接、加前缀或换键;仅调用一次合法project-progress。
+
+node /opt/data/skills/ksf-workshop-runtime/scripts/ksf-secure.mjs project-progress --agent-profile <本条核实的完整agentProfileKey> --input /opt/data/ksf-workshop-private/S5-classroom-completion-01/01-project-complete.json --request-id {steps[1]['requestId']}
+
+核外层ok=true、entryResult.ok=true、EXIT=0,逐条解释stderr;取projectId=S_CikxZeBs、stageCode=S5、progressState=completed、eventId非空、enterpriseApproval=false。独占保存结果编号;不再执行任何保存或建卡动作。
+
+第03步:复用第01同一只读输入,project-status读回。核项目S5/completed/live/status=workshop,三个原成果ID不变、currentConceptRef及conceptId/version不变,规格null、空配方/工艺与13问题不变;事件恰8条,前7条ID/顺序不变,末条eventId等第02返回值、sequence=8、eventType=completed、fromStage/toStage均S5、projectRef/sessionRef正确、note逐字等模板。项目status字段仍workshop,completed落在progressState,不要求status=completed。
+
+命令读取格式:node /opt/data/skills/ksf-workshop-runtime/scripts/ksf-secure.mjs project-status --input /opt/data/ksf-workshop-private/S5-classroom-completion-01/00-project-status.json --request-id <runbook本步原样requestId>。
+三组留存前缀固定01-precheck、02-complete、03-final-status,各.stdout.json/.stderr.log/.exitcode;编号写04-result-ids.json。不把写命令内置的归属只读计成第四个CLI步骤;业务命令恰3步,其中1写2读。未开生产浏览器则browserVerified=false,后续Codex单独验收,不沿用旧轮true当本次已验。
+
+三、停止与边界
+
+任何失败、超时或不确定立即停,保留原requestId、全部输入/回执和已返回编号;不自动重试、换键、删记录或回滚。错误不等于零写入;第03读失败不因此再次完成。由Codex按编号核对再决定恢复。
+
+预期仅新增1条completed事件与1条成功操作审计,更新当前项目/会话进度;不新增报告/正文来源/概念/卡/项目/产品/配方/工艺。不改S1—S5正文、13问题、Skill/Function/schema/wrapper/前端,不做招募/试吃/采购,不代员工确认或正式发布。历史S5实际保存键与54号偏差保留,不补跑规定键;本次完成不声称全程完全符合执行单。
+
+回报三步完整requestId、两层invocationId、退出码/stderr、eventId、项目阶段/状态/3成果/1概念/8事件/13问题及实际留存清单权限。不得把课堂completed写成需求已验证或产品已具备量产条件。
+'''
+put('58-发给王刚001-课堂完成与回读.txt', actor)
+put('README.txt', '课堂收尾固定材料包。先读56号,再57号运维落地/只读预检,通过后58号由王刚001真实消息执行。\n仅1个project-progress完成写命令,前后各1次project-status。模板sessionId/projectId=null,不可直接提交。\n预期S5/completed、原3报告/1概念不变、8事件及13未决问题。正式确认/发布和研究验证未发生。\nS5原保存键偏差保留,不补存报告,不清空未决项。无凭据、浏览器档案或完整私聊。\n')
+put_json('preflight.json', {'ok': True, 'localStructureOnly': True, 'productionBusinessWrites': 0, 'completed': False, 'browserVerified': False, 'templateSha256': template_hash, 'pendingQuestionCount': 13})
+print(json.dumps({'ok': True, 'templates': 1, 'templateBytes': len(template_bytes), 'templateSha256': template_hash, 'businessApiCalls': 0, 'productionBusinessWrites': 0}, ensure_ascii=True))

+ 76 - 0
scripts/prepare-board-deployment.py

@@ -0,0 +1,76 @@
+"""Freeze the pushed board commit; never package uncommitted code or private data."""
+import concurrent.futures
+import hashlib
+import io
+import json
+from pathlib import Path
+import subprocess
+import urllib.request
+import zipfile
+
+ROOT = Path(__file__).resolve().parents[1]
+COMMIT = '0ba3c0a07afb70aed54713739d3c128dacc1aaf0'
+ORIGIN = 'https://kangshifu.fmode.cn'
+OUT = ROOT / 'work/board-deploy-20261106-r1'
+PKG = OUT / 'package'
+sha = lambda b: hashlib.sha256(b).hexdigest()
+norm = lambda b: sha(b.replace(b'\r\n', b'\n'))
+
+def remote(p):
+    req = urllib.request.Request(ORIGIN + '/' + p, headers={'Cache-Control': 'no-cache'})
+    with urllib.request.urlopen(req, timeout=35) as r:
+        return r.read(), r.headers.get('content-type', '')
+
+def prepare():
+    if PKG.exists():
+        raise RuntimeError('PACKAGE_ALREADY_EXISTS')
+    source = zipfile.ZipFile(io.BytesIO(subprocess.check_output(['git', 'archive', '--format=zip', COMMIT], cwd=ROOT)))
+    members = [p for p in source.namelist() if not p.endswith('/') and (
+        (p.startswith('src/') and not p.startswith('src/dev/')) or
+        p.startswith(('server/src/', 'server/references/', 'server/web/', 'server/test/', 'test/')) or
+        p in {'package.json', 'server/scripts/build-member-groups.mjs', 'server/library-api.mjs'} or
+        (p.startswith('server/voc-library/') and not Path(p).name.startswith('test_')))]
+    PKG.mkdir(parents=True)
+    for p in members:
+        target = PKG / 'payload' / p
+        target.parent.mkdir(parents=True, exist_ok=True)
+        target.write_bytes(source.read(p))
+    front = sorted(p for p in members if p.startswith('src/'))
+    index, _ = remote('index.html')
+    production = dict(zip(front, concurrent.futures.ThreadPoolExecutor(max_workers=6).map(remote, front)))
+    files = []
+    unknown = []
+    for p in front:
+        body, kind = production[p]
+        after = source.read(p)
+        # SPA fallback is an absent module, never a successfully deployed JavaScript file.
+        absent = body == index and 'text/html' in kind
+        baseline = [] if absent else [norm(body)]
+        known = absent or norm(body) == norm(after)
+        if not known:
+            for revision in ('bb38e3732e8ae102ff98345abfee5c1caba13d71', 'd14d02f', '7d01b5c'):
+                before = subprocess.run(['git', 'show', revision + ':' + p], cwd=ROOT, capture_output=True)
+                if before.returncode == 0 and norm(before.stdout) == norm(body):
+                    known = True
+                    break
+        if not known:
+            unknown.append(p)
+            target = OUT / 'production-before' / p
+            target.parent.mkdir(parents=True, exist_ok=True)
+            target.write_bytes(body)
+        files.append({'path': p, 'sha256': sha(after), 'priorNormalizedSha256': baseline,
+                      'allowMissing': absent, 'observedState': 'spa-fallback' if absent else 'static-file',
+                      'observedSha256': sha(body), 'changed': absent or norm(body) != norm(after),
+                      'matchesKnownGitVersion': known})
+    manifest = {'version': 'ksf.board.publish.v1', 'release': 'ksf.board.20261106.r1',
+                'commit': COMMIT, 'origin': ORIGIN, 'homepageSha256': sha(index), 'files': files}
+    (PKG / 'deploy').mkdir()
+    (PKG / 'deploy/board-files.json').write_text(json.dumps(manifest, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
+    receipt = {'commit': COMMIT, 'staticFiles': len(files), 'changedFiles': sum(x['changed'] for x in files),
+               'newFiles': sum(x['allowMissing'] for x in files), 'unknownProductionBaselines': unknown,
+               'homepageSha256': sha(index), 'productionWrites': 0, 'productionBrowserVerified': False}
+    (OUT / 'preparation.json').write_text(json.dumps(receipt, ensure_ascii=False, indent=2), encoding='utf-8')
+    print(json.dumps(receipt, ensure_ascii=False))
+
+if __name__ == '__main__':
+    prepare()

+ 233 - 0
scripts/prepare-consumer-quote-inputs.py

@@ -0,0 +1,233 @@
+"""Prepare fixed consumer-quote inputs and handoff files. No network or business writes."""
+from pathlib import Path
+import hashlib
+import json
+import sys
+import zipfile
+
+ROOT = Path(__file__).resolve().parents[1]
+SOURCE = ROOT / 'work/consumer-research-codex-20261102/delivery'
+ARCHIVE = ROOT / 'work/consumer-quotes-codex-20261102.zip'
+DEST = ROOT / 'work/consumer-quote-inputs-20261102'
+DOCS = Path(r'F:\文档\工作坊当前资料')
+TASK = 'KSF-消费者原话-20261102-01'
+ARCHIVE_SHA = 'df741b09e0540c21cc311ea81e560beb87c0ec7a85a1872ad704f17a7b5e4ac1'
+REMOTE_INPUTS = '/opt/data/ksf-executor-20261102/consumer-quotes-01'
+REMOTE_STATE = '/opt/data/ksf-workshop-private/consumer-quotes/KSF-consumer-quotes-20261102-01'
+REMOTE_ARCHIVE = '/opt/data/ksf-workshop-private/archive-packages/consumer-quotes-codex-20261102.zip'
+PACKAGE = DOCS / 'ksf-consumer-quotes-import-20261102.zip'
+sha = lambda blob: hashlib.sha256(blob).hexdigest()
+encode = lambda value: (json.dumps(value, ensure_ascii=False, indent=2) + '\n').encode('utf-8')
+
+
+def retain(path, blob):
+    if path.exists():
+        assert path.read_bytes() == blob, f'Existing file differs; preserve it: {path}'
+    else:
+        path.parent.mkdir(parents=True, exist_ok=True)
+        with path.open('xb') as f:
+            f.write(blob)
+
+
+def prepare():
+    archive = ARCHIVE.read_bytes()
+    assert len(archive) == 1287263 and sha(archive) == ARCHIVE_SHA
+    with zipfile.ZipFile(ARCHIVE) as z:
+        assert z.testzip() is None and len(z.namelist()) == 30
+        manifest = z.read('FILES.sha256').decode('utf-8').splitlines()
+        assert len(manifest) == 29
+        for line in manifest:
+            expected, name = line.split('  ', 1)
+            assert sha(z.read(name)) == expected
+            assert z.read(name) == (SOURCE / name).read_bytes()
+        data = json.loads(z.read('consumer-quotes.json'))
+    assert len(data['records']) == 4 and data['businessDataWritten'] is False
+    inputs = {}
+    for row in data['records']:
+        key = row['id']
+        original_locator = row['locator']
+        raw = row['completeMainBody']
+        start, end = original_locator['startCharacter'], original_locator['endCharacter']
+        assert raw[start:end] == row['quote']
+        assert sha(raw.encode('utf-8')) == row['hashes']['mainTextSha256']
+        locator = {
+            'url': row['sourceUrl'], 'section': '原帖主文章正文中的使用体验自述',
+            'startCharacter': start, 'endCharacter': end,
+            'unit': 'Unicode code point', 'startBase': 0, 'endExclusive': True,
+            'publishedAtReported': row['publishedAtReported'], 'publicationTimezone': 'unknown',
+            'httpCollectedAt': row['httpCollectedAt'], 'browserReadAt': row['browserCollectedAt'],
+            'archiveSha256': ARCHIVE_SHA,
+            'textOrigin': {'file': original_locator['file'], 'sha256': row['hashes']['mainTextSha256'],
+                           'extraction': original_locator['extraction']},
+            'responseOriginal': {'file': row['files']['response'], 'sha256': row['hashes']['originalResponseSha256']},
+            'screenshot': {'file': row['files']['screenshot'], 'sha256': row['hashes']['screenshotSha256']},
+            'articleDom': {'file': row['files']['articleDom'], 'sha256': row['hashes']['articleDomSha256']},
+            'browserReadFile': row['files']['browserRead'],
+            'collectionPerformedBy': 'Codex',
+            'productScope': row['productScope'], 'productIdentificationBasis': row['productIdentificationBasis'],
+            'context': row['context'], 'purchaseVerified': False, 'realIdentityVerified': False,
+            'commercialRelationship': 'unknown', 'visibleTopic': row['visibleTopic'],
+            'limitations': data['limits'],
+        }
+        item = {
+            'itemKey': key + '-experience', 'className': 'KfEvidence',
+            'externalKey': TASK + ':' + key + ':experience:v1',
+            'data': {
+                'dataMode': 'live', 'quote': row['quote'],
+                'evidenceKind': 'public_user_experience_self_report',
+                'platform': 'smzdm', 'authorLabel': row['authorDisplay'],
+                'parentObjectId': row['sourceUrl'].rstrip('/').split('/')[-1],
+                'quoteForm': 'verbatim_excerpt',
+                'verificationState': 'archived_text_and_screenshot_checked',
+                'publishedAt': None, 'collectedAt': row['browserCollectedAt'],
+                'locator': locator,
+            },
+        }
+        task = {
+            'source': {'title': row['title'], 'sourceKind': 'social', 'dataMode': 'live',
+                       'sourceUrl': row['sourceUrl'], 'platform': 'smzdm',
+                       'rawText': raw, 'collectedAt': row['httpCollectedAt']},
+            'records': [item],
+        }
+        assert 'productRef' not in item['data'] and 'sourceRef' not in item['data']
+        inputs[key + '.input.json'] = task
+        retain(DEST / (key + '.input.json'), encode(task))
+
+    review = {
+        'ok': True, 'operation': 'consumer-quote-draft-reviewed',
+        'basis': '王刚001草稿由用户转述;原话与固定本地原件独立对照。远端文件权限和其哈希验算只记为王刚001回报。',
+        'taskKey': TASK, 'originalArchiveSha256': ARCHIVE_SHA, 'originalArchiveBytes': len(archive),
+        'sourceCount': 4, 'evidenceCount': 4, 'platformCount': 1,
+        'corrections': [
+            '明确表达面量少的是①cq-03、②cq-04,共2篇;③cq-02没有说量少。问题表中“③②同系列”应为“①②”。',
+            '①既说“面量真少”“真不够吃”,也批评青豆;不能说唯一明确批评是青豆,也不将“看着还可以吧”写成整体正面。',
+            '“作为一餐是否足够”是研究问题,不是①②的逐字原话;自加食材的动机与具体用餐场景保持待验证。',
+            '最新帖发表于2024-10-10,距2026-11-02已超过2年。“近两年没有样本”成立;“快2年”应改为“超过2年”。',
+            '④是作者留水改善口感的经验,不能推断所有留水量的效果,也不能因作者语气正面就断言其从未感觉干。',
+            '“吃腻泡面想尝鲜”“元旦前买”等明确自述可保留,人口特征、真实用餐场景、动机及需求强度不能补写。',
+        ],
+        'sampleObservations': {'portionComments': ['cq-03', 'cq-04'],
+                               'boilingMentioned': ['cq-02', 'cq-03', 'cq-04'],
+                               'unnoticedBeforePurchase': ['cq-02'],
+                               'drainingWaterAdvice': ['cq-01'], 'peaDislike': ['cq-03']},
+        'sampleProportionsArePopulationEstimates': False,
+        'draftReadyForInputPreparation': True, 's2Saved': False,
+        'inputHashes': {name: sha(encode(value)) for name, value in inputs.items()},
+        'businessWrites': 0,
+    }
+    retain(DEST / 'draft-review.json', encode(review))
+    review_text = '''消费者原话草稿核对结论
+
+草稿可以用于继续准备资料入库,尚不是已保存的S2成果。四篇原话、出处与产品区分符合原件,保留正负上下文、小样本和历史时间边界。
+
+保存前修正以下表述:
+1. 量少的是①和②,共2篇,不是3篇;问题表中“③②”应改成“①②”。
+2. ①明确批评了面量和青豆,不能说唯一批评是青豆。“看着还可以吧”指加料后的外观,不足以断定整体正面。
+3. “作为一餐是否足够”是我们提出的研究问题,不是作者逐字说的场景。自加挂面、蛋、菜的实际动机待验证。
+4. 最新一篇为2024年10月10日,距2026年11月2日已超过2年;“近两年没有样本”成立,“快2年”应改为“超过2年”。
+5. 留水建议属于作者改善口感的经验,不推出普遍嫌干或普遍不嫌干。已有的尝鲜、元旦前购买等原话可保留,未明示的人群和用餐场景保持未知。
+
+下一步只导入4份完整正文及4条主引文,不创建产品或分类,不把燃魂与香辣合成SKU。来源为公开用户自述,购买、真实身份及商业关系未认证。发表时间只保留页面原字符串,时区未知;本次取得时间使用原采集日志,不混淆两者。
+
+固定输入、原件哈希与本地隔离预检完成后交运维准备,再由王刚001真实消息任务执行。入库后独立回读通过,才准备S2保存模板;S1第2版G8IrkkDG7X保持原样。
+'''
+    retain(DOCS / '19-消费者原话草稿核对结果.txt', review_text.encode('utf-8'))
+    return inputs, review, review_text
+
+
+def package(inputs, review, review_text):
+    verification = ROOT / 'work/consumer-quote-inputs-verified-20261102.json'
+    local = json.loads(verification.read_text('utf-8'))
+    assert local['ok'] and local['productionBusinessWrites'] == 0 and len(local['checks']) == 4
+    assert local['inputHashes'] == review['inputHashes']
+    ops = f'''运维001:准备4篇消费者原话入库。只落地固定输入和留存目录,不代跑业务。
+
+包:ksf-consumer-quotes-import-20261102.zip。先核FILES.sha256全部项。inputs下4份cq-01至cq-04.input.json原样放到{REMOTE_INPUTS}/,root:root 0644、hermes可读。沿用现有ksf-secure.mjs,不安装新执行器,不改Skill/Function/schema,不重启。
+
+包内archives/consumer-quotes-codex-20261102.zip为原采集包原字节,1287263B,SHA-256={ARCHIVE_SHA}。保存到{REMOTE_ARCHIVE},hermes:hermes 0600,父目录0700。若该路径已有同字节原包,核对后直接复用;已有不同文件则保留现场并停止,不覆盖、不重打包。
+
+王刚001已回报解压原件目录/opt/data/ksf-workshop-private/consumer-quotes-codex-20261102/;若存在,核其29项FILES.sha256与原包一致后复用,不覆盖。若不存在,用已核原包在全新目录解压,目录0700、文件0600。不得将既有0条采集目录改写或清空。
+
+只读核对固定任务键{TASK}的历史、本地回执与在途,并按输入4个sourceUrl、source.rawText内容哈希及4个externalKey查服务端。已有相同资料可复用;如发现该任务已执行或在途,回报实际编号并暂停,不换键重跑。
+
+以hermes(10000)、HOME=/opt/data准备留存父目录及{REMOTE_STATE},0700。目标若已存在,不清空或换目录,先核历史。本批只计划KfSource+KfEvidence,不需要新身份文件。
+
+回报:4份输入哈希/权限、同一原包实际路径/字节/完整哈希及hermes实读、原件清单核对、历史/在途与留存目录状态。没有执行workshop-import就是预期。准备完成后才交王刚001按包内执行单入库一次;无需立即保存S2。
+'''
+    wang = f'''王刚001:运维准备完成后,在一条新的真实消息任务里,使用本条已核实的现有agentProfileKey,通过现有secure入口导入4份固定消费者原话输入。你不需要重新设计或修改内容。
+
+草稿修正:量少为①②共2篇;①同时批评面量和青豆;“作为一餐”是研究问题;最新帖距今超过2年。完整核对见包内草稿核对结果.txt。
+
+固定任务键{TASK}。沿用身份,不登记新Agent或员工。先查本任务无既有执行回执、无在途;若有则只回报,不重放。以hermes(10000)、HOME=/opt/data执行。
+
+按cq-01、cq-02、cq-03、cq-04顺序逐份执行。前一份导入及来源/证据精确回读都通过后才做下一份。下面以cq-01为例,其余只将命令和回执前缀中的cq-01替换为对应cq-02/03/04;不改输入、不换任务键。
+
+1. 只读查重:
+node /opt/data/skills/ksf-workshop-runtime/scripts/ksf-secure.mjs workshop-dedup --input {REMOTE_INPUTS}/cq-01.input.json --request-id {TASK}:cq-01:preflight:v1
+
+新来源no-exact-match、条目deferred/SOURCE_NOT_REGISTERED是正常组合;来源unchanged和条目reuse也是可接受组合。先确认外层ok和entryResult.ok都为true,只有以上可解释结果才继续,其他异常停止回报。
+
+2. 导入一次:
+node /opt/data/skills/ksf-workshop-runtime/scripts/ksf-secure.mjs workshop-import --agent-profile <本条已核实agentProfileKey> --input {REMOTE_INPUTS}/cq-01.input.json --request-id {TASK}:cq-01:import:v1
+
+核外层ok和entryResult.ok均为true,取entryResult.result的sourceId、sourceOperation、items[0].objectId、items[0].jobId、operation及counts。不以退出码替代两层回执核对。
+
+3. 分别用真实返回编号独占写两份只读输入(0600),格式为{{"className":"KfSource","objectId":"<实际sourceId>"}}和{{"className":"KfEvidence","objectId":"<实际evidenceId>"}},然后:
+node /opt/data/skills/ksf-workshop-runtime/scripts/ksf-secure.mjs workshop-read --input <来源回读输入绝对路径> --request-id {TASK}:cq-01:read-source:v1
+node /opt/data/skills/ksf-workshop-runtime/scripts/ksf-secure.mjs workshop-read --input <证据回读输入绝对路径> --request-id {TASK}:cq-01:read-evidence:v1
+
+核对来源输入各字段逐项一致:social/live/smzdm、标题、URL、完整正文、collectedAt;证据输入各字段逐项一致:quote、locator、authorLabel、parentObjectId、evidenceKind=public_user_experience_self_report、verificationState=archived_text_and_screenshot_checked、quoteForm=verbatim_excerpt、publishedAt=null和实际collectedAt。sourceRef指回本次来源,productRef为空。locator按Unicode码点切片等于quote,原包/正文/截图哈希与实际文件吻合。发表时间保留locator.publishedAtReported原字符串,时区未知;不得用采集时间替代发表时间。不能只看“读接口成功”就算内容通过。
+
+两份回读均通过后再处理下一份。所有命令各执行一次。输入快照、只读输入、stdout/stderr、退出码和两层回执独占留存到{REMOTE_STATE}/,0600,不覆盖既有文件。查重、写入、回读任何一步失败即停,保留原requestId和已取得编号;不自动重试/换键/删记录,错误不代表零写入。
+
+成功回报4篇各自sourceId、evidenceId、jobId、sourceOperation、created/unchanged、import两层invocationId及逐项回读。可为unchanged,不为凑新增改键。collectionPerformedBy为Codex,实际导入执行者为你,不声称你刚刚重新抓取了平台。
+
+本次只做原话资料入库:4来源、每来源1条主引文;不建KfCategory/KfProduct,不保存S2,不生成机会卡/项目,不改S1 G8IrkkDG7X,不重跑旧K轮或旧导入。3篇燃魂、1篇香辣不合SKU,均不挂到-KbX0Agka-。不称已验证购买、真实身份或普遍需求,也不据历史帖子认定当前商品价格/规格/库存。
+'''
+    retain(DOCS / '20-发给运维001-消费者原话入库准备.txt', ops.encode('utf-8'))
+    retain(DOCS / '21-发给王刚001-消费者原话入库.txt', wang.encode('utf-8'))
+    blobs = {'inputs/' + name: encode(value) for name, value in inputs.items()}
+    blobs.update({
+        'archives/consumer-quotes-codex-20261102.zip': ARCHIVE.read_bytes(),
+        'draft-review.json': encode(review),
+        '草稿核对结果.txt': review_text.encode('utf-8'),
+        '给运维001.txt': ops.encode('utf-8'), '给王刚001.txt': wang.encode('utf-8'),
+        'local-verification.json': verification.read_bytes(),
+    })
+    history_file = ROOT / 'work/consumer-quote-import-history-20261102.json'
+    if history_file.exists():
+        blobs['readonly-history.json'] = history_file.read_bytes()
+    blobs['FILES.sha256'] = ''.join(sha(b) + '  ' + n + '\n' for n, b in blobs.items()).encode('utf-8')
+    if PACKAGE.exists():
+        with zipfile.ZipFile(PACKAGE) as z:
+            assert set(z.namelist()) == set(blobs)
+            for n, b in blobs.items():
+                assert z.read(n) == b
+    else:
+        with zipfile.ZipFile(PACKAGE, 'x', compression=zipfile.ZIP_DEFLATED) as z:
+            for name, blob in blobs.items():
+                z.writestr(name, blob)
+    with zipfile.ZipFile(PACKAGE) as z:
+        assert z.testzip() is None
+        for line in z.read('FILES.sha256').decode('utf-8').splitlines():
+            expected, name = line.split('  ', 1)
+            assert sha(z.read(name)) == expected
+    receipt = {'ok': True, 'operation': 'consumer-quote-inputs-packaged',
+               'taskKey': TASK, 'file': str(PACKAGE), 'bytes': PACKAGE.stat().st_size,
+               'sha256': sha(PACKAGE.read_bytes()), 'manifestChecked': len(blobs) - 1,
+               'sourcesPlanned': 4, 'evidencePlanned': 4, 'productsPlanned': 0,
+               'reportsPlanned': 0, 'targetExecuted': False, 'businessWrites': 0,
+               'originalArchiveSha256': ARCHIVE_SHA, 'inputHashes': review['inputHashes']}
+    retain(ROOT / 'work/consumer-quote-import-package-receipt-20261102.json', encode(receipt))
+    return receipt
+
+
+if __name__ == '__main__':
+    sys.stdout.reconfigure(encoding='utf-8')
+    prepared = prepare()
+    if '--package' in sys.argv:
+        print(json.dumps(package(*prepared), ensure_ascii=False, indent=2))
+    else:
+        print(json.dumps({'ok': True, 'operation': 'consumer-quote-inputs-prepared',
+                          'taskKey': TASK, 'inputHashes': prepared[1]['inputHashes'],
+                          'businessWrites': 0}, ensure_ascii=False, indent=2))

+ 68 - 0
scripts/prepare-first-live-S1.py

@@ -0,0 +1,68 @@
+"""Prepare a source-linked S1 draft and executable input template; no remote writes."""
+from pathlib import Path
+import json
+import hashlib
+import zipfile
+
+ROOT=Path(__file__).resolve().parents[1]
+OUT=ROOT/'work/delivery-by-recipient-20261102'
+KEY='KSF-真实资料-20261102-01'
+
+def main():
+    bundle=json.loads((ROOT/'server/test/fixtures/first-live-sources-20261102.json').read_text(encoding='utf-8'))
+    source_ids=['BgUB6dnxN1','SFiKA0D7n9']
+    title='S1真实资料首批小结:统一拌面官网条目与待补问题'
+    executive=['已实际取得并入库统一官网2页,整理9个口味条目、9条关联引文和1个研究工作分类;9条引文实际来自2段官网口味/包装列表,来源厂商只有1家。', '资料仅支持记录官网列出的口味和系列包装;不能当作9个独立SKU、当前在售清单、销量或消费者需求证据。', '已完成:官网采集、原件归档、查重入库及独立回读。尚缺:康师傅同口径产品、SKU与价格、消费者评价,以及后续供应链与技术依据。']
+    questions=[
+        '优先纳入比较的康师傅拌面系列、具体产品名或官方资料入口有哪些?先补自有产品,建立相同粒度的对照。',
+        '各口味目前是否在售?请以有日期的官方商品页或销售页面核对;官网展示时间不能代替上市时间。',
+        '每个口味对应哪些包装和净含量?需逐SKU核实,并保留配料表、营养标签原件,不把系列包装直接分配给所有口味。',
+        '先观察哪些渠道、地区和店铺?每条价格需同时记录日期、包装数量、优惠条件及标价/实付口径,才能比较单份价格。',
+        '消费者对口味、面体、酱量、沥水/拌制、分量与价格具体说了什么?需保存真实逐字评价和来源,并保留正负及无明显倾向内容;这些维度目前只是采集问题。',
+        '后续如进入新品方案,哪些配方比例、原料规格、工艺参数、成本和法规资料可由企业提供?资料未到前保持未验证,不推导量产可行。'
+    ]
+    rows=[[f['name'],f['packagingStatement'],'统一官网;系列级包装,未拆SKU'] for f in bundle['facts']]
+    sections=[{'kind':'para','title':'资料范围与取得方式','text':'2026年11月2日由Codex读取并归档两页统一官网,王刚001导入归档资料。数据为live,保留取得时间与原文;官网发布日期未知。两页均为厂商自述,未做独立消费或产品性能验证。'}, {'kind':'table','title':'已掌握的9个口味条目','data':{'cols':['口味条目','官网包装说明','口径'], 'rows':rows}}, {'kind':'para','title':'证据与计数说明','text':'2份原始来源,9个产品口味实体,9个产品关联证据实体,引用内容来自2段列表。不能把关联实体数当作独立证据来源数;本批不代表完整竞品样本。研究工作分类用于整理,并非官方行业分类。'}, {'kind':'table','title':'可以说明什么、还不能说明什么','data':{'cols':['维度','当前可确认','仍缺资料'], 'rows':[['产品与口味','官网列示上述口味','当前在售状态、上市日期'],['包装与规格','两系列官网包装说明','逐口味包装、净含量、配料和营养标签'],['价格与渠道','本批没有采集','店铺、地区、日期、价格及优惠口径'],['用户与需求','本批没有消费者材料','逐字评价、使用场景、痛点和需求强度'],['市场与技术','不能由本批推断','完整竞品口径、销量/份额、配方/工艺/成本/法规']]}}, {'kind':'table','title':'待补问题与建议顺序','data':{'cols':['顺序','具体问题','状态'], 'rows':[[str(i),q,'待补'] for i,q in enumerate(questions,1)]}}, {'kind':'para','title':'本轮工作进度','text':'样例全链路、桌面/手机页面和跨批次查重已实际验收。第一批真实官网资料已入库并独立回读;本小结保存后才算本轮S1成果可在看板查看。未做消费者调研、机会立项、新品验证、正式确认或正式报告发布。'}]
+    evidence={}
+    for i,b in enumerate(bundle['batches']):
+        evidence['official-'+str(i+1)]={'sourceId':source_ids[i], 'claim':'仅佐证官网列示的口味和系列包装,不佐证消费者偏好或当前在售', 'quotes':[{'text':next(r['data']['quote'] for r in b['task']['records'] if r['className']=='KfEvidence'), 'ref':b['capture']['url']+';'+b['task']['source']['collectedAt']+'取得;官网产品口味/包装段'}]}
+    report={'executive':executive,'chapters':[{'id':'s1-first-live','title':'首批官网资料与研究缺口','stage':'S1','sections':sections}],'evidence':evidence}
+    raw=title+'\n\n'+'\n'.join(executive)+'\n\n'+'\n'.join(' | '.join(r) for r in rows)+'\n\n'+'\n'.join(questions)+'\n\n厂商自述;不是消费者研究、完整市场结论或正式审批。'
+    task={'sessionId':None,'taskKey':KEY+':S1','version':1,'stageCode':'S1','title':title,'artifactType':'competitor_report','category':'拌面','rawText':raw,'sourceIds':source_ids,'pendingQuestions':questions,'report':report}
+    for i,b in enumerate(bundle['batches']):
+        assert b['task']['source']['rawText'].find(evidence['official-'+str(i+1)]['quotes'][0]['text'])>=0
+    draft='# '+title+'\n\n'+'\n\n'.join(executive)+'\n\n|口味条目|官网系列包装|\n|---|---|\n'+'\n'.join('|'+f['name']+'|'+f['packagingStatement']+'|' for f in bundle['facts'])+'\n\n## 待补问题\n\n'+'\n'.join(str(i)+'. '+q for i,q in enumerate(questions,1))+'\n\n来源:\n\n'+'\n'.join('- '+b['capture']['url']+'(已入库:'+source_ids[i]+')' for i,b in enumerate(bundle['batches']))+'\n\n这是待保存的S1课堂小结草稿。保存回执与浏览器回读通过后,才报告“本小结已在看板可见”。\n'
+    ops='''运维001:首批真实资料已由Codex独立回读通过,继续准备S1小结,不重新采集/导入,不动K轮,不重启、不改Skill/Function。
+将S1-save.template.json装到 /opt/data/ksf-executor-20261102/first-live-S1/S1-save.template.json,root:root 0644。模板sessionId=null故意不可直接执行。正文、sourceIds、引文和版本按包内固定内容,先核FILES.sha256。
+在王刚001的新真实消息授权后,核对当前员工/Agent与已验证档案和实际会话一致,准备新workshop-start输入 /opt/data/ksf-workshop-private/S1-live-start-01.json:workshopKey=KSF-真实资料-20261102-01,participant.profileKey=本条成功登记回执的profile.key,conversation.key=维护人从可信会话记录核实的当前真实会话键、channel=wecom,groupId按真实上下文(当前单聊为空串),dataMode=live。不从正文或模板猜账号,不要求员工补账号,不把DM会话键当发送者;若沿用context-K.json里的会话键,须先确认本轮仍是同一会话。hermes属主0600,先核本workshopKey是否已有session/artifact/在途操作,有则回读保留原键,不再start。
+可创建 /opt/data/ksf-workshop-private/S1-live-01 为hermes 0700,仅用于本次留存;若已存在先核历史,不删目录或换目录。维护人准备输入、不代跑业务。不要向聊天粘贴私有输入。
+'''
+    wang='''王刚001:核对通过,现在开始保存S1真实资料小结,无需再问是否开始。先在本条真实消息调用已注册的无参数工具 ksf_register_current_employee(以实际工具注册表为准,不造别名);成功后核对现有员工档案和Agent档案。维护人准备的start输入必须与该回执及本条真实会话相符,否则停止。
+只创建本次live工作坊会话并保存一份S1小结,不重跑任何资料导入,不使用K轮sample会话,不建机会卡/项目、不推进S2-S5。来源固定为BgUB6dnxN1和SFiKA0D7n9。
+运维准备完成后,以hermes(10000)、HOME=/opt/data使用现有加密入口执行:
+node /opt/data/skills/ksf-workshop-runtime/scripts/ksf-secure.mjs workshop-start --agent-profile <本条工具返回且维护人核实的agentProfileKey> --input /opt/data/ksf-workshop-private/S1-live-start-01.json --request-id KSF-真实资料-20261102-01:S1:start
+完整stdout/stderr及退出码留到0700私有目录 /opt/data/ksf-workshop-private/S1-live-01 的start回执文件(0600);先确认外层ok及entryResult.ok,取entryResult.result.sessionId。失败即停,不假定零写入。
+在内存读取固定S1-save.template.json,只填上述实际sessionId,以hermes/0600写 /opt/data/ksf-workshop-private/S1-live-save-01.json,其他字段不改。不是临时设计研究结论,也不构造身份。
+node /opt/data/skills/ksf-workshop-runtime/scripts/ksf-secure.mjs workshop-save --agent-profile <同一agentProfileKey> --input /opt/data/ksf-workshop-private/S1-live-save-01.json --request-id KSF-真实资料-20261102-01:S1:save:v1
+留存完整两层回执,取sessionId/artifactId后用workshop-read精确回读该KfArtifact,确认live、S1、dashboardState=workshop、两份来源引用与小结正文。回读输入另存0600,不修改原保存输入。不要把来源页数量写成9,不把9个口味写成9个SKU,不补价格或用户偏好。
+成功回报sessionId、artifactId、reportUrl=https://kangshifu.fmode.cn/report/<artifactId>、stageCode/dataMode/boardVisible/formalReportPublished/employeeConfirmed和两层invocationId。browserVerified只有实际开浏览器检查正文后才能为true。任何失败保留原requestId/编号/目录,不自动重试、换键、删除记录或重新导入。
+'''
+    files={'S1-save.template.json':(json.dumps(task,ensure_ascii=False,indent=2)+'\n').encode(),'S1小结与待补问题.md':draft.encode(),'给运维001-S1小结准备.txt':ops.encode(),'给王刚001-S1小结保存.txt':wang.encode()}
+    digest=lambda b:hashlib.sha256(b).hexdigest()
+    files['FILES.sha256']=''.join(digest(b)+'  '+n+'\n' for n,b in files.items()).encode()
+    OUT.mkdir(parents=True,exist_ok=True)
+    target=OUT/'ksf-first-live-S1-20261102.zip'
+    with zipfile.ZipFile(target,'w',compression=zipfile.ZIP_DEFLATED) as z:
+        for n,b in files.items():z.writestr(n,b)
+    with zipfile.ZipFile(target) as z:
+        assert z.testzip() is None
+        for line in z.read('FILES.sha256').decode().splitlines():
+            h,n=line.split('  ',1);assert digest(z.read(n))==h
+    for n,b in files.items():(OUT/('first-live-S1-'+n)).write_bytes(b)
+    (ROOT/'docs/first-live-S1-20261102.md').write_text(draft,encoding='utf-8')
+    fixture=ROOT/'server/test/fixtures/first-live-S1-20261102.json';fixture.write_bytes(files['S1-save.template.json'])
+    receipt={'file':target.name,'bytes':target.stat().st_size,'sha256':digest(target.read_bytes()),'manifestChecked':'4/4','sourceIds':source_ids,'targetSaved':False}
+    (OUT/'ksf-first-live-S1-20261102.receipt.json').write_text(json.dumps(receipt,ensure_ascii=False,indent=2),encoding='utf-8')
+    print(json.dumps(receipt,ensure_ascii=False,indent=2))
+
+if __name__=='__main__':main()

+ 231 - 0
scripts/prepare-live-S2.py

@@ -0,0 +1,231 @@
+"""Prepare a source-backed S2 draft and delivery package; never saves production data."""
+from pathlib import Path
+import hashlib
+import json
+import sys
+import zipfile
+
+ROOT = Path(__file__).resolve().parents[1]
+WORK = ROOT / 'work'
+DOCS = Path(r'F:\文档\工作坊当前资料')
+TEMPLATE = WORK / 'first-live-S2-template.json'
+TASK_KEY = 'KSF-真实资料-20261102-01:S2'
+SESSION = 'btrFqkz0yw'
+REQUEST_ID = TASK_KEY + ':save:v1'
+REMOTE_TEMPLATE = '/opt/data/ksf-executor-20261102/first-live-S2/S2-save.template.json'
+REMOTE_INPUT = '/opt/data/ksf-workshop-private/S2-live-save-01.json'
+REMOTE_STATE = '/opt/data/ksf-workshop-private/S2-live-01'
+sha = lambda b: hashlib.sha256(b).hexdigest()
+encode = lambda value: (json.dumps(value, ensure_ascii=False, indent=2) + '\n').encode('utf-8')
+
+
+def retain(path, blob):
+    if path.exists():
+        assert path.read_bytes() == blob, f'Preserve different existing file: {path}'
+    else:
+        path.parent.mkdir(parents=True, exist_ok=True)
+        with path.open('xb') as f:
+            f.write(blob)
+
+
+def prepare():
+    verified = json.loads((WORK / 'consumer-quotes-readback-20261102-complete.json').read_text('utf-8'))
+    assert verified['ok'] and verified['cq04EvidenceIndependentlyVerified']
+    assert verified['sources'] == verified['evidence'] == 4 and verified['businessWrites'] == 0
+    collection = json.loads((WORK / 'consumer-research-codex-20261102/delivery/consumer-quotes.json').read_text('utf-8'))
+    by_key = {r['id']: r for r in collection['records']}
+    imported = verified['imports']
+    rows = []
+    evidence = {}
+    claims = {
+        'cq-01': '仅记录香辣拌面作者留水改善口感的个人经验;不证明普遍嫌干,也不推广到燃魂拌面。',
+        'cq-02': '仅记录燃魂拌面作者购前未注意煮制要求;同段肯定简单和口感,不证明普遍误导。',
+        'cq-03': '仅记录燃魂拌面作者认为面量不够并自行加面;份量问题和自加动机需补充验证。',
+        'cq-04': '仅记录燃魂拌面作者肯定味道并指出面量不大、需要煮;不代表市场普遍需求。',
+    }
+    for ids in imported:
+        key = ids['key']
+        original = by_key[key]
+        input_task = json.loads((WORK / 'consumer-quote-inputs-20261102' / (key + '.input.json')).read_text('utf-8'))
+        assert original['quote'] == input_task['records'][0]['data']['quote']
+        assert original['completeMainBody'] == input_task['source']['rawText']
+        rows.append([key, '香辣拌面;系列/SKU未核实' if key == 'cq-01' else '燃魂拌面;SKU未核实',
+                     original['authorDisplay'] + ';' + original['publishedAtReported'],
+                     original['quote'], original['context']])
+        evidence['consumer-' + key] = {
+            'sourceId': ids['sourceId'], 'claim': claims[key],
+            'quotes': [{'text': original['quote'],
+                        'ref': original['sourceUrl'] + ';页面发表时间=' + original['publishedAtReported']
+                        + '(时区未知);2026-11-02采集;evidenceId=' + ids['evidenceId']}],
+        }
+    questions = [
+        '能否补充近两年、不同平台的具体使用体验,并确认涉及的产品是否已换版?现有4篇最新为2024-10-10。',
+        '对明确到规格的燃魂拌面,受访者实际在什么场景吃、期待多少份量?自行加面或配料是习惯还是为补足食量?',
+        '用户购买前能否辨认燃魂拌面的煮制要求?当前包装和商品页面如何说明?不把1篇购前未注意推为普遍误导。',
+        '对同一款香辣拌面,在可比条件下留水量与拌匀、干燥口感有什么关系?不将其混同燃魂拌面的煮制过程。',
+        '对青豆口感的单例不满,是否有更多正反体验、具体产品版本与配料信息支持进一步研究?',
+        '目标人群、具体用餐场景、问题频率、影响程度与支付意愿是什么?需补访谈或问卷,不凭小样本给KANO分档。',
+    ]
+    report = {
+        'subtitle': '4篇历史公开用户自述;用于提出问题,尚未验证市场需求',
+        'method': '按可访问且有具体使用体验的原帖取便利样本;原话、上下文、出处和截图核对后入库。',
+        'sources': '什么值得买4篇、4个不同显示账号;发表时间2023—2024年,实际采集时间2026-11-02。',
+        'executive': [
+            '已保存4篇公开用户使用体验及4条主引文,来自同一平台。购买记录、作者真实身份与商业关系未独立认证,不能据此估计人群比例。',
+            '3篇涉及燃魂拌面,1篇涉及香辣拌面,精确SKU及与当前商品的匹配未知,不合并为同一款产品。',
+            '样本内2篇明确说量少,3篇提及需要煮,其中仅1篇说购买前没注意;另1篇提出留少量水改善口感。这些是个案描述,不是已验证的普遍痛点。',
+            '最新一篇发表于2024-10-10,距2026-11-02已超过2年。人群特征、具体用餐场景、需求强度、支付意愿和当前商品参数仍待补。',
+        ],
+        'chapters': [
+            {'id': 's2-original-experiences-v1', 'title': '用户实际说了什么', 'stage': 'S2', 'sections': [
+                {'kind': 'para', 'title': '阅读范围', 'text': '只陈述这4篇里的话,保留正负上下文。帖子的旧价格和克重属于作者历史自述,不当作当前参数。原文“添加剂”字词原样保留;“作为一餐是否足够”是研究问题,不是作者逐字场景。'},
+                {'kind': 'table', 'title': '4篇主引文与上下文', 'data': {'cols': ['条目', '产品范围', '显示作者与发表时间', '逐字原话', '必要上下文'], 'rows': rows}},
+            ]},
+            {'id': 's2-questions-v1', 'title': '值得继续核实的问题', 'stage': 'S2', 'sections': [
+                {'kind': 'table', 'title': '从原话到问题', 'data': {
+                    'cols': ['问题', '依据', '目前不能认定'],
+                    'rows': [
+                        ['燃魂拌面的煮制要求在购买前是否清楚?', 'cq-02购前未注意;cq-03、cq-04也提到需要煮。', '不能认定消费者普遍被误导,cq-02同时肯定操作简单、Q弹爽口。'],
+                        ['份量是否满足具体用餐需求?', 'cq-03和cq-04共2篇说量少;出现自加面或食材。', '不认定普遍要求加量;自加动机、用餐场景和具体规格未知。'],
+                        ['香辣拌面留水量是否影响干燥口感?', 'cq-01建议沥水时留少量水,并说香辣味好吃。', '不认定普遍嫌干,也不把经验推广到燃魂或全部康师傅拌面。'],
+                        ['青豆口感是否值得进一步验证?', 'cq-03完整来源正文对青豆有单例不满,同时明确批评面量。', '只有1篇提及,列为低证据线索;不认定普遍配料缺陷。'],
+                    ],
+                }},
+                {'kind': 'para', 'title': '保留已有的具体叙述', 'text': '原帖出现吃腻泡面后尝鲜、元旦前购买、自加挂面和配菜等自述。可以保留这些个案信息;不能据此补写学生宿舍、加班夜宵等人群与场景。'},
+            ]},
+            {'id': 's2-unknowns-v1', 'title': '尚未知道什么、接下来补什么', 'stage': 'S2', 'sections': [
+                {'kind': 'table', 'title': '关键未知项', 'data': {
+                    'cols': ['项目', '当前状态'],
+                    'rows': [
+                        ['年龄、性别、城市及家庭结构', '未知;账号显示名不等于已核实的真实消费者身份。'],
+                        ['具体用餐时间、地点、目的与自加动机', '未明确部分保持未知,不从购买日期推出食用场景。'],
+                        ['需求频率、强度、支付意愿与KANO分档', '未知;未做访谈、代表性调查或KANO问卷。'],
+                        ['当前商品匹配、规格、库存及可比单价', '未知;不与苏宁、华姐商品或已有牛肉主线产品记录硬关联。'],
+                    ],
+                }},
+                {'kind': 'table', 'title': '待补问题', 'data': {'cols': ['顺序', '问题', '状态'], 'rows': [[str(i + 1), q, '待验证'] for i, q in enumerate(questions)]}},
+                {'kind': 'para', 'title': '本阶段边界', 'text': '本小结记录历史个案与待验证问题。资料取得模式为live,但不意味着内容新近或需求已经验证;尚未生成机会卡、建立项目、开展企业正式审批或发布正式报告。S1第2版作为既有阶段记录保留。'},
+            ]},
+        ],
+        'evidence': evidence,
+    }
+    task = {'sessionId': None, 'taskKey': TASK_KEY, 'version': 1, 'stageCode': 'S2',
+            'title': 'S2用户需求与痛点:4篇历史使用体验与待验证问题',
+            'artifactType': 'pain_list', 'category': '拌面',
+            'sourceIds': [r['sourceId'] for r in imported], 'pendingQuestions': questions, 'report': report}
+    paragraphs = [task['title'], *report['executive']]
+    for chapter in report['chapters']:
+        paragraphs.append(chapter['title'])
+        for section in chapter['sections']:
+            paragraphs.append(section['title'])
+            if section['kind'] == 'para':
+                paragraphs.append(section['text'])
+            else:
+                paragraphs.append(' | '.join(section['data']['cols']))
+                paragraphs.extend(' | '.join(row) for row in section['data']['rows'])
+    for entry in evidence.values():
+        paragraphs.append(entry['claim'])
+        paragraphs.extend(q['text'] + '\n' + q['ref'] for q in entry['quotes'])
+    task['rawText'] = '\n\n'.join(paragraphs)
+    retain(TEMPLATE, encode(task))
+    review = '''# S2小结审阅稿
+
+拟保存:S2用户需求与痛点,第1版,沿用现有live会话。当前只完成模板准备,尚未保存。
+
+已经知道:4篇什么值得买历史使用体验已入库,并由Codex独立核对通过。3篇涉及燃魂拌面,1篇涉及香辣拌面;样本内2篇说量少,3篇提及需要煮,其中1篇说购前没注意;1篇给出留水改善口感的经验。青豆不满只有单例,保留为低证据线索。
+
+这些是个案。保留味道不错、操作简单等正面上下文,不把“看着还可以吧”当作整款产品的总体评价。自加面和配菜的动机、具体用餐场景仍未知,“一餐够不够”只是待验证问题。
+
+仍不知道:更近期及不同平台的体验、当前商品版本和SKU、人群特征、需求频率与强度、支付意愿。原帖发表于2023—2024年,最新一篇已超过2年;实际采集时间为2026年11月2日,二者分开。没有已购或真实身份认证,也没有KANO问卷结果。
+
+本次保存将引用已入库4来源、4条主引文,形成可查看的课堂过程小结。保留S1第2版,不重导资料,不生成机会卡、不建项目,不代表市场需求已验证或正式报告已发布。保存成功后再独立核对后台和电脑/手机页面。
+'''
+    retain(WORK / 'first-live-S2-review.md', review.encode('utf-8'))
+    retain(DOCS / 'S2小结第1版-审阅稿.md', review.encode('utf-8'))
+    return task, verified, review
+
+
+def package(task, verified, review):
+    check_path = WORK / 'first-live-S2-local-check.json'
+    local = json.loads(check_path.read_text('utf-8'))
+    assert local['ok'] and local['productionBusinessWrites'] == 0
+    assert local['templateSha256'] == sha(TEMPLATE.read_bytes())
+    ops = f'''运维001:准备真实S2小结第1版,只装模板和只读核查,不代跑保存。
+
+包:ksf-live-S2-20261102.zip。核FILES.sha256全部项;将S2-save.template.json原样安装到{REMOTE_TEMPLATE},root:root 0644、hermes可读。模板sessionId=null,不可直接提交。沿用现有secure入口,不改Skill/Function/schema,不重启。
+
+沿用现有live会话{SESSION},workshopKey=KSF-真实资料-20261102-01,已有S1报告G8IrkkDG7X。只读核对当前Agent、员工与真实会话归属一致,不猜账号、不新登记身份;确认4来源2mzBadmvws/Pi04eTwTQ3/m1OQx9aynA/QdeMcyTTjX及4证据FO47R0yR8D/rD8XZgV1Xo/_9mUml0wmh/2bFGP7axMo存在且live。Codex已完成独立回读,包括此前未完成的cq-04,禁止重导任何一份。
+
+检查报告系列{TASK_KEY}、version=1、requestId={REQUEST_ID}的服务端历史/本地回执/在途。若已有任何执行或结果不明先回读报告,不重放、不换键。本地拟生成输入{REMOTE_INPUT}及S2回执文件均须原本不存在;如已有则停下核查。
+
+以hermes(10000)、HOME=/opt/data建{REMOTE_STATE},0700;目标已存在则不清空或换目录,先核状态。私有保存输入由王刚001独占新建为0600,本轮运维不生成可执行保存输入、不代跑业务。
+
+回报模板哈希/权限、身份会话归属、源证据及S2历史核对、留存目录及在途状态。所有旧S1、消费者原话及0条采集回执保留。原cq-04读失败回执保留,无需补跑原命令。随后交王刚001按包内保存单执行一次。
+'''
+    wang = f'''王刚001:4份消费者原话及导入回执已经由Codex独立核对通过,含cq-04证据2bFGP7axMo。原失败回执保留,不再重试或重导入。
+
+本轮在新的真实消息任务保存S2第1版,沿用live会话{SESSION}。先使用现有无参数ksfregistercurrent_employee工具取得本条真实身份回执,确认当前Agent及员工与既有会话、S1 G8IrkkDG7X归属一致。不创建新会话,不用旧私有身份文件冒充当前消息。
+
+运维准备核查通过后,固定taskKey={TASK_KEY},version=1,stageCode=S2,artifactType=pain_list,requestId={REQUEST_ID}。若已有该系列版本、requestId回执或在途,停止并回读,不换键重放。
+
+以hermes(10000)、HOME=/opt/data读{REMOTE_TEMPLATE}到内存,仅填sessionId={SESSION},其余字段逐项不改;差异必须只有sessionId。独占新建{REMOTE_INPUT},hermes/0600,已存在就停,不覆盖,不打印完整输入。
+
+执行一次:
+node /opt/data/skills/ksf-workshop-runtime/scripts/ksf-secure.mjs workshop-save --agent-profile <本条已核实的agentProfileKey> --input {REMOTE_INPUT} --request-id {REQUEST_ID}
+
+stdout/stderr/退出码以S2-v1-save前缀独占留存到{REMOTE_STATE}/,0600。核外层ok和entryResult.ok均为true后,取entryResult.result的sessionId/artifactId/sourceId。失败、超时或不确定立即停,保留原requestId和所有编号,不自动重试/换键/删记录;错误不等于零写入。
+
+成功后用实际新artifactId独占写0600只读输入{REMOTE_STATE}/S2-v1-read.json,内容为{{"className":"KfArtifact","objectId":"<实际artifactId>"}},执行:
+node /opt/data/skills/ksf-workshop-runtime/scripts/ksf-secure.mjs workshop-read --input {REMOTE_STATE}/S2-v1-read.json --request-id {TASK_KEY}:read:v1
+
+核两层ok,以及version=1、stageCode=S2、dataMode=live、dashboardState=workshop;ownerSession/sessionRef指向{SESSION}。sourceIds含4份已核来源,4段主引文、6条待补问题、完整rawText和report.chapters/evidence与模板逐项相符。服务端会加课堂声明、报告标题和generatedAt,按合同核对;私有meta不可读则交Codex核查,不补造。只读失败也停止并保留回执,交Codex按编号核对,禁止因此再次save。
+
+回报sessionId、artifactId、生成正文sourceId、reportUrl、版本/阶段/dataMode、boardVisible、formalReportPublished、employeeConfirmed、两层invocationId及精确回读。未打开浏览器则browserVerified=false,页面由Codex后续独立验收。
+
+本次不重导资料、不建产品/分类/机会卡/项目,不改S1或旧样例。S2是4篇历史个案与待验证问题;2篇量少,1篇购前未注意煮制,1篇留水经验,不据此宣布普遍需求或市场机会。保存可能使会话显示当前S2,并生成本小结正文来源,这是本次正常结果,不是重新抓取。
+'''
+    closure = '''消费者原话入库:独立核对通过
+
+4来源、4条主引文及4个导入任务均通过。cq-04证据2bFGP7axMo已由Codex经ksfGetRecord按编号回读,quote、locator、作者、时间及来源关联与固定输入一致。其余3份也已独立对照,原件哈希与字符定位一致;4个任务均succeeded,每来源只有1条对应证据,无重复源或额外产品。
+
+王刚001保留原cq-04失败回执即可,不必重发原只读命令,更不能重跑导入。原REMOTE_REQUEST_UNCERTAIN的具体原因没有据本次成功回读反推,仍记未查明。Codex本轮只读,业务写入0。
+
+S1第2版G8IrkkDG7X仍与原模板一致,S2尚未保存。下一步准备S2第1版,见23号运维说明与24号保存单。
+'''
+    retain(DOCS / '22-消费者原话入库核对结果.txt', closure.encode('utf-8'))
+    retain(DOCS / '23-发给运维001-S2第1版准备.txt', ops.encode('utf-8'))
+    retain(DOCS / '24-发给王刚001-S2第1版保存.txt', wang.encode('utf-8'))
+    members = {
+        'S2-save.template.json': TEMPLATE.read_bytes(), 'S2小结第1版-审阅稿.md': review.encode('utf-8'),
+        '给运维001.txt': ops.encode('utf-8'), '给王刚001.txt': wang.encode('utf-8'),
+        'consumer-quotes-independent-readback.json': encode(verified), 'local-verification.json': check_path.read_bytes(),
+    }
+    members['FILES.sha256'] = ''.join(sha(b) + '  ' + name + '\n' for name, b in members.items()).encode('utf-8')
+    target = DOCS / 'ksf-live-S2-20261102.zip'
+    assert not target.exists(), 'Preserve existing package'
+    with zipfile.ZipFile(target, 'x', compression=zipfile.ZIP_DEFLATED) as z:
+        for name, blob in members.items():
+            z.writestr(name, blob)
+    with zipfile.ZipFile(target) as z:
+        assert z.testzip() is None
+        for line in z.read('FILES.sha256').decode('utf-8').splitlines():
+            expected, name = line.split('  ', 1)
+            assert sha(z.read(name)) == expected
+    receipt = {'ok': True, 'file': str(target), 'bytes': target.stat().st_size, 'sha256': sha(target.read_bytes()),
+               'templateSha256': sha(TEMPLATE.read_bytes()), 'manifestChecked': len(members) - 1,
+               'taskKey': TASK_KEY, 'requestId': REQUEST_ID, 'version': 1, 'stageCode': 'S2',
+               'sourceIds': task['sourceIds'], 'reportQuotes': 4, 'pendingQuestions': 6,
+               'productionBusinessWrites': 0, 'targetSaved': False, 'browserVerified': False}
+    retain(WORK / 'first-live-S2-package-receipt.json', encode(receipt))
+    return receipt
+
+
+if __name__ == '__main__':
+    sys.stdout.reconfigure(encoding='utf-8')
+    values = prepare()
+    if '--package' in sys.argv:
+        print(json.dumps(package(*values), ensure_ascii=False, indent=2))
+    else:
+        print(json.dumps({'ok': True, 'template': str(TEMPLATE), 'sha256': sha(TEMPLATE.read_bytes()),
+                          'sourceIds': values[0]['sourceIds'], 'reportQuotes': 4,
+                          'pendingQuestions': 6, 'productionBusinessWrites': 0}, ensure_ascii=False, indent=2))

+ 100 - 0
scripts/publish-opportunity-ui.mjs

@@ -0,0 +1,100 @@
+import fs from 'node:fs';
+import path from 'node:path';
+import crypto from 'node:crypto';
+import {fileURLToPath} from 'node:url';
+
+export const FILES=Object.freeze([
+ 'src/components/kf-opportunity-card.js','src/pages/view-pool.js','src/utils/router.js',
+ 'src/services/board-service.js','src/services/workshop-data.js','src/layout/kf-app.js'
+]);
+const sha=bytes=>crypto.createHash('sha256').update(bytes).digest('hex');
+const normalized=bytes=>sha(Buffer.from(bytes.toString('utf8').replaceAll('\r\n','\n')));
+const fail=(code,file)=>{throw Object.assign(Error(code),{code,...(file?{file}:{})});};
+const plain=file=>{const s=fs.lstatSync(file);if(!s.isFile()||s.isSymbolicLink())fail('NOT_PLAIN_FILE',file);return s;};
+function safePath(root,relative){
+ const target=path.resolve(root,relative);if(!target.startsWith(root+path.sep))fail('PATH_OUTSIDE_TARGET');
+ let current=root;
+ for(const part of relative.split('/')){
+  current=path.join(current,part);
+  if(fs.existsSync(current)&&fs.lstatSync(current).isSymbolicLink())fail('SYMLINK_PATH',relative);
+ }
+ return target;
+}
+function unchanged(file,before){
+ if(before===null){if(fs.existsSync(file))fail('CONCURRENT_CHANGE');}
+ else if(!fs.existsSync(file)||sha(fs.readFileSync(file))!==before)fail('CONCURRENT_CHANGE');
+}
+function atomicWrite(file,bytes,mode){
+ const temp=file+'.ksf-update-'+crypto.randomUUID();let fd;
+ try{fd=fs.openSync(temp,'wx',mode);fs.writeFileSync(fd,bytes);fs.fsyncSync(fd);fs.closeSync(fd);fd=undefined;fs.renameSync(temp,file);}
+ finally{if(fd!==undefined)fs.closeSync(fd);if(fs.existsSync(temp))fs.unlinkSync(temp);}
+}
+
+/** Filesystem adapter only. Target is supplied by the site maintainer, never inferred from HTTP. */
+export function publishFiles({sourceDir,manifestPath,targetDir,apply=false},dependencies={}){
+ if(!path.isAbsolute(sourceDir)||!path.isAbsolute(targetDir))fail('ABSOLUTE_PATH_REQUIRED');
+ sourceDir=path.resolve(sourceDir);targetDir=path.resolve(targetDir);
+ if(fs.realpathSync(targetDir)!==targetDir||targetDir===path.parse(targetDir).root)fail('UNSAFE_TARGET_DIRECTORY');
+ if(sourceDir===targetDir||sourceDir.startsWith(targetDir+path.sep)||targetDir.startsWith(sourceDir+path.sep))fail('SOURCE_TARGET_OVERLAP');
+ const manifest=JSON.parse(fs.readFileSync(manifestPath,'utf8'));
+ if(manifest.version!=='ksf.opportunity-ui.publish.v1'||manifest.release!=='ksf.opportunity-ui.20261103.v1'||!/^[a-f0-9]{64}$/.test(manifest.homepageSha256))fail('BAD_MANIFEST');
+ if(!Array.isArray(manifest.files)||JSON.stringify(manifest.files.map(f=>f.path).sort())!==JSON.stringify([...FILES].sort()))fail('UNEXPECTED_FILE_LIST');
+ const index=safePath(targetDir,'index.html');plain(index);const indexHash=sha(fs.readFileSync(index));
+ if(indexHash!==manifest.homepageSha256)fail('HOMEPAGE_MISMATCH','index.html');
+ const byName=new Map(manifest.files.map(f=>[f.path,f]));
+ const entries=FILES.map(relative=>{
+  const def=byName.get(relative),source=safePath(sourceDir,relative),target=safePath(targetDir,relative);plain(source);
+  if(!/^[a-f0-9]{64}$/.test(def.sha256)||!Array.isArray(def.priorNormalizedSha256)||def.priorNormalizedSha256.some(h=>!/^[a-f0-9]{64}$/.test(h)))fail('BAD_MANIFEST');
+  const bytes=fs.readFileSync(source);if(sha(bytes)!==def.sha256&&normalized(bytes)!==def.sha256)fail('PAYLOAD_HASH_MISMATCH',relative);
+  const stat=fs.existsSync(target)?plain(target):null,before=stat?fs.readFileSync(target):null;
+  const same=before!==null&&normalized(before)===normalized(bytes);
+  if(before&&!same&&!def.priorNormalizedSha256.includes(normalized(before)))fail('UNKNOWN_TARGET_CONTENT',relative);
+  if(!stat&&!def.allowMissing)fail('BASELINE_FILE_MISSING',relative);
+  return{relative,target,bytes,before,beforeHash:before===null?null:sha(before),mode:stat?stat.mode&0o777:0o644,same};
+ });
+ const changes=entries.filter(e=>!e.same);
+ const base={operation:apply?'filesystem-update':'read-only-plan',readOnly:!apply,targetDir,release:manifest.release,filesChecked:6,changes:changes.map(e=>e.relative),unchanged:entries.filter(e=>e.same).map(e=>e.relative),homepageSha256:indexHash,homepageModified:false,networkRequests:0,cdnCacheRefreshed:false,publicSiteVerified:false};
+ if(!apply||!changes.length)return{ok:true,...base,backup:null};
+
+ const lock=path.join(path.dirname(targetDir),'.'+path.basename(targetDir)+'.ksf-opportunity-ui.lock');
+ const lockFd=fs.openSync(lock,'wx',0o600);let backup=null;const done=[],createdDirs=[];
+ try{
+  backup=fs.mkdtempSync(path.join(path.dirname(targetDir),'ksf-opportunity-ui-backup-'));
+  const journal={at:new Date().toISOString(),targetDir,release:manifest.release,homepageSha256:indexHash,status:'prepared',files:changes.map(e=>({path:e.relative,beforeSha256:e.beforeHash,afterSha256:sha(e.bytes)}))};
+  const writeJournal=()=>fs.writeFileSync(path.join(backup,'journal.json'),JSON.stringify(journal,null,2),{mode:0o600});
+  // Save every prior file before replacing the first one.
+  for(const e of changes)if(e.before!==null){const p=path.join(backup,e.relative);fs.mkdirSync(path.dirname(p),{recursive:true});fs.writeFileSync(p,e.before,{flag:'wx',mode:0o600});}
+  writeJournal();
+  for(const e of changes){
+   unchanged(index,indexHash);safePath(targetDir,e.relative);unchanged(e.target,e.beforeHash);
+   const absent=[];let parent=path.dirname(e.target);while(!fs.existsSync(parent)){absent.push(parent);parent=path.dirname(parent);}
+   for(const dir of absent.reverse()){fs.mkdirSync(dir);createdDirs.push(dir);}
+   dependencies.beforeWrite?.(e.relative);
+   atomicWrite(e.target,e.bytes,e.mode);done.push(e);
+  }
+  for(const e of entries){plain(e.target);if(normalized(fs.readFileSync(e.target))!==normalized(e.bytes))fail('TARGET_VERIFY_FAILED',e.relative);}
+  unchanged(index,indexHash);journal.status='verified';writeJournal();
+  return{ok:true,...base,backup,filesystemVerified:true,publicVerificationRequired:true};
+ }catch(error){
+  let rollbackComplete=true;
+  for(const e of [...done].reverse())try{
+   // Never overwrite a concurrent publisher's version while rolling back.
+   unchanged(e.target,sha(e.bytes));
+   if(e.before===null)fs.unlinkSync(e.target);else atomicWrite(e.target,e.before,e.mode);
+  }catch{rollbackComplete=false;}
+  for(const dir of [...createdDirs].reverse())try{fs.rmdirSync(dir);}catch{}
+  if(backup)try{const p=path.join(backup,'journal.json');const j=JSON.parse(fs.readFileSync(p));fs.writeFileSync(p,JSON.stringify({...j,status:'stopped',rollbackComplete},null,2));}catch{}
+  error.backup=backup;error.rollbackComplete=rollbackComplete;throw error;
+ }finally{fs.closeSync(lockFd);fs.unlinkSync(lock);}
+}
+
+const self=fileURLToPath(import.meta.url);
+if(process.argv[1]&&path.resolve(process.argv[1])===self){
+ const stamp={invocationId:crypto.randomUUID(),at:new Date().toISOString()};
+ try{
+  const argv=process.argv.slice(2);let targetDir,apply=false;
+  for(let i=0;i<argv.length;i++){if(argv[i]==='--apply'&&!apply)apply=true;else if(argv[i]==='--target-dir'&&!targetDir&&argv[i+1]&&!argv[i+1].startsWith('--'))targetDir=argv[++i];else fail('USAGE');}
+  if(!targetDir)fail('USAGE');const root=path.resolve(path.dirname(self),'..');
+  console.log(JSON.stringify({...stamp,...publishFiles({sourceDir:root,manifestPath:path.join(root,'deploy/opportunity-ui-files-20261103.json'),targetDir,apply})},null,2));
+ }catch(e){console.error(JSON.stringify({...stamp,ok:false,error:{code:/^[A-Z][A-Z0-9_]+$/.test(e.code||'')?e.code:'PUBLISH_STOPPED',...(FILES.includes(e.file)||e.file==='index.html'?{file:e.file}:{}),message:'发布停止;核对源站目录、首页和白名单文件,不自动修改校验基准。'},...(e.backup?{backup:e.backup,rollbackComplete:e.rollbackComplete}:{})},null,2));process.exitCode=1;}
+}

+ 99 - 0
scripts/publish-report-evidence.mjs

@@ -0,0 +1,99 @@
+import fs from 'node:fs';
+import path from 'node:path';
+import crypto from 'node:crypto';
+import {fileURLToPath} from 'node:url';
+
+export const FILES=Object.freeze([
+ 'src/core/report.js','src/core/evidence/chain.js','src/layout/kf-app.js'
+]);
+const sha=bytes=>crypto.createHash('sha256').update(bytes).digest('hex');
+const normalized=bytes=>sha(Buffer.from(bytes.toString('utf8').replaceAll('\r\n','\n')));
+const fail=(code,file)=>{throw Object.assign(Error(code),{code,...(file?{file}:{})});};
+const plain=file=>{const s=fs.lstatSync(file);if(!s.isFile()||s.isSymbolicLink())fail('NOT_PLAIN_FILE',file);return s;};
+function safePath(root,relative){
+ const target=path.resolve(root,relative);if(!target.startsWith(root+path.sep))fail('PATH_OUTSIDE_TARGET');
+ let current=root;
+ for(const part of relative.split('/')){
+  current=path.join(current,part);
+  if(fs.existsSync(current)&&fs.lstatSync(current).isSymbolicLink())fail('SYMLINK_PATH',relative);
+ }
+ return target;
+}
+function unchanged(file,before){
+ if(before===null){if(fs.existsSync(file))fail('CONCURRENT_CHANGE');}
+ else if(!fs.existsSync(file)||sha(fs.readFileSync(file))!==before)fail('CONCURRENT_CHANGE');
+}
+function atomicWrite(file,bytes,mode){
+ const temp=file+'.ksf-update-'+crypto.randomUUID();let fd;
+ try{fd=fs.openSync(temp,'wx',mode);fs.writeFileSync(fd,bytes);fs.fsyncSync(fd);fs.closeSync(fd);fd=undefined;fs.renameSync(temp,file);}
+ finally{if(fd!==undefined)fs.closeSync(fd);if(fs.existsSync(temp))fs.unlinkSync(temp);}
+}
+
+/** Filesystem adapter only. Target is supplied by the site maintainer, never inferred from HTTP. */
+export function publishFiles({sourceDir,manifestPath,targetDir,apply=false},dependencies={}){
+ if(!path.isAbsolute(sourceDir)||!path.isAbsolute(targetDir))fail('ABSOLUTE_PATH_REQUIRED');
+ sourceDir=path.resolve(sourceDir);targetDir=path.resolve(targetDir);
+ if(fs.realpathSync(targetDir)!==targetDir||targetDir===path.parse(targetDir).root)fail('UNSAFE_TARGET_DIRECTORY');
+ if(sourceDir===targetDir||sourceDir.startsWith(targetDir+path.sep)||targetDir.startsWith(sourceDir+path.sep))fail('SOURCE_TARGET_OVERLAP');
+ const manifest=JSON.parse(fs.readFileSync(manifestPath,'utf8'));
+ if(manifest.version!=='ksf.report-evidence.publish.v1'||manifest.release!=='ksf.report-evidence.20261102.v1'||!/^[a-f0-9]{64}$/.test(manifest.homepageSha256))fail('BAD_MANIFEST');
+ if(!Array.isArray(manifest.files)||JSON.stringify(manifest.files.map(f=>f.path).sort())!==JSON.stringify([...FILES].sort()))fail('UNEXPECTED_FILE_LIST');
+ const index=safePath(targetDir,'index.html');plain(index);const indexHash=sha(fs.readFileSync(index));
+ if(indexHash!==manifest.homepageSha256)fail('HOMEPAGE_MISMATCH','index.html');
+ const byName=new Map(manifest.files.map(f=>[f.path,f]));
+ const entries=FILES.map(relative=>{
+  const def=byName.get(relative),source=safePath(sourceDir,relative),target=safePath(targetDir,relative);plain(source);
+  if(!/^[a-f0-9]{64}$/.test(def.sha256)||!Array.isArray(def.priorNormalizedSha256)||def.priorNormalizedSha256.some(h=>!/^[a-f0-9]{64}$/.test(h)))fail('BAD_MANIFEST');
+  const bytes=fs.readFileSync(source);if(sha(bytes)!==def.sha256&&normalized(bytes)!==def.sha256)fail('PAYLOAD_HASH_MISMATCH',relative);
+  const stat=fs.existsSync(target)?plain(target):null,before=stat?fs.readFileSync(target):null;
+  const same=before!==null&&normalized(before)===normalized(bytes);
+  if(before&&!same&&!def.priorNormalizedSha256.includes(normalized(before)))fail('UNKNOWN_TARGET_CONTENT',relative);
+  if(!stat&&!def.allowMissing)fail('BASELINE_FILE_MISSING',relative);
+  return{relative,target,bytes,before,beforeHash:before===null?null:sha(before),mode:stat?stat.mode&0o777:0o644,same};
+ });
+ const changes=entries.filter(e=>!e.same);
+ const base={operation:apply?'filesystem-update':'read-only-plan',readOnly:!apply,targetDir,release:manifest.release,filesChecked:3,changes:changes.map(e=>e.relative),unchanged:entries.filter(e=>e.same).map(e=>e.relative),homepageSha256:indexHash,homepageModified:false,networkRequests:0,cdnCacheRefreshed:false,publicSiteVerified:false};
+ if(!apply||!changes.length)return{ok:true,...base,backup:null};
+
+ const lock=path.join(path.dirname(targetDir),'.'+path.basename(targetDir)+'.ksf-report-evidence.lock');
+ const lockFd=fs.openSync(lock,'wx',0o600);let backup=null;const done=[],createdDirs=[];
+ try{
+  backup=fs.mkdtempSync(path.join(path.dirname(targetDir),'ksf-report-evidence-backup-'));
+  const journal={at:new Date().toISOString(),targetDir,release:manifest.release,homepageSha256:indexHash,status:'prepared',files:changes.map(e=>({path:e.relative,beforeSha256:e.beforeHash,afterSha256:sha(e.bytes)}))};
+  const writeJournal=()=>fs.writeFileSync(path.join(backup,'journal.json'),JSON.stringify(journal,null,2),{mode:0o600});
+  // Save every prior file before replacing the first one.
+  for(const e of changes)if(e.before!==null){const p=path.join(backup,e.relative);fs.mkdirSync(path.dirname(p),{recursive:true});fs.writeFileSync(p,e.before,{flag:'wx',mode:0o600});}
+  writeJournal();
+  for(const e of changes){
+   unchanged(index,indexHash);safePath(targetDir,e.relative);unchanged(e.target,e.beforeHash);
+   const absent=[];let parent=path.dirname(e.target);while(!fs.existsSync(parent)){absent.push(parent);parent=path.dirname(parent);}
+   for(const dir of absent.reverse()){fs.mkdirSync(dir);createdDirs.push(dir);}
+   dependencies.beforeWrite?.(e.relative);
+   atomicWrite(e.target,e.bytes,e.mode);done.push(e);
+  }
+  for(const e of entries){plain(e.target);if(normalized(fs.readFileSync(e.target))!==normalized(e.bytes))fail('TARGET_VERIFY_FAILED',e.relative);}
+  unchanged(index,indexHash);journal.status='verified';writeJournal();
+  return{ok:true,...base,backup,filesystemVerified:true,publicVerificationRequired:true};
+ }catch(error){
+  let rollbackComplete=true;
+  for(const e of [...done].reverse())try{
+   // Never overwrite a concurrent publisher's version while rolling back.
+   unchanged(e.target,sha(e.bytes));
+   if(e.before===null)fs.unlinkSync(e.target);else atomicWrite(e.target,e.before,e.mode);
+  }catch{rollbackComplete=false;}
+  for(const dir of [...createdDirs].reverse())try{fs.rmdirSync(dir);}catch{}
+  if(backup)try{const p=path.join(backup,'journal.json');const j=JSON.parse(fs.readFileSync(p));fs.writeFileSync(p,JSON.stringify({...j,status:'stopped',rollbackComplete},null,2));}catch{}
+  error.backup=backup;error.rollbackComplete=rollbackComplete;throw error;
+ }finally{fs.closeSync(lockFd);fs.unlinkSync(lock);}
+}
+
+const self=fileURLToPath(import.meta.url);
+if(process.argv[1]&&path.resolve(process.argv[1])===self){
+ const stamp={invocationId:crypto.randomUUID(),at:new Date().toISOString()};
+ try{
+  const argv=process.argv.slice(2);let targetDir,apply=false;
+  for(let i=0;i<argv.length;i++){if(argv[i]==='--apply'&&!apply)apply=true;else if(argv[i]==='--target-dir'&&!targetDir&&argv[i+1]&&!argv[i+1].startsWith('--'))targetDir=argv[++i];else fail('USAGE');}
+  if(!targetDir)fail('USAGE');const root=path.resolve(path.dirname(self),'..');
+  console.log(JSON.stringify({...stamp,...publishFiles({sourceDir:root,manifestPath:path.join(root,'deploy/report-evidence-files-20261102.json'),targetDir,apply})},null,2));
+ }catch(e){console.error(JSON.stringify({...stamp,ok:false,error:{code:/^[A-Z][A-Z0-9_]+$/.test(e.code||'')?e.code:'PUBLISH_STOPPED',...(FILES.includes(e.file)||e.file==='index.html'?{file:e.file}:{}),message:'发布停止;核对源站目录、首页和白名单文件,不自动修改校验基准。'},...(e.backup?{backup:e.backup,rollbackComplete:e.rollbackComplete}:{})},null,2));process.exitCode=1;}
+}

+ 103 - 0
scripts/publish-workshop-files.mjs

@@ -0,0 +1,103 @@
+import fs from 'node:fs';
+import path from 'node:path';
+import crypto from 'node:crypto';
+import {fileURLToPath} from 'node:url';
+
+export const FILES=Object.freeze([
+ 'src/services/workshop-data.js',
+ 'lib/vendor/signal-polyfill/v0.2.2/dist/index.js',
+ 'src/app.config.js','src/core/kfs/build.js','src/core/library/reports.js',
+ 'src/core/opportunity/opportunity.js','src/core/taxonomy/taxonomy.js',
+ 'src/pages/view-members.js','src/services/board-service.js'
+]);
+const sha=bytes=>crypto.createHash('sha256').update(bytes).digest('hex');
+const normalized=bytes=>sha(Buffer.from(bytes.toString('utf8').replaceAll('\r\n','\n')));
+const fail=(code,file)=>{throw Object.assign(Error(code),{code,...(file?{file}:{})});};
+const plain=file=>{const s=fs.lstatSync(file);if(!s.isFile()||s.isSymbolicLink())fail('NOT_PLAIN_FILE',file);return s;};
+function safePath(root,relative){
+ const target=path.resolve(root,relative);if(!target.startsWith(root+path.sep))fail('PATH_OUTSIDE_TARGET');
+ let current=root;
+ for(const part of relative.split('/')){
+  current=path.join(current,part);
+  if(fs.existsSync(current)&&fs.lstatSync(current).isSymbolicLink())fail('SYMLINK_PATH',relative);
+ }
+ return target;
+}
+function unchanged(file,before){
+ if(before===null){if(fs.existsSync(file))fail('CONCURRENT_CHANGE');}
+ else if(!fs.existsSync(file)||sha(fs.readFileSync(file))!==before)fail('CONCURRENT_CHANGE');
+}
+function atomicWrite(file,bytes,mode){
+ const temp=file+'.ksf-update-'+crypto.randomUUID();let fd;
+ try{fd=fs.openSync(temp,'wx',mode);fs.writeFileSync(fd,bytes);fs.fsyncSync(fd);fs.closeSync(fd);fd=undefined;fs.renameSync(temp,file);}
+ finally{if(fd!==undefined)fs.closeSync(fd);if(fs.existsSync(temp))fs.unlinkSync(temp);}
+}
+
+/** Filesystem adapter only. Target is supplied by the site maintainer, never inferred from HTTP. */
+export function publishFiles({sourceDir,manifestPath,targetDir,apply=false},dependencies={}){
+ if(!path.isAbsolute(sourceDir)||!path.isAbsolute(targetDir))fail('ABSOLUTE_PATH_REQUIRED');
+ sourceDir=path.resolve(sourceDir);targetDir=path.resolve(targetDir);
+ if(fs.realpathSync(targetDir)!==targetDir||targetDir===path.parse(targetDir).root)fail('UNSAFE_TARGET_DIRECTORY');
+ if(sourceDir===targetDir||sourceDir.startsWith(targetDir+path.sep)||targetDir.startsWith(sourceDir+path.sep))fail('SOURCE_TARGET_OVERLAP');
+ const manifest=JSON.parse(fs.readFileSync(manifestPath,'utf8'));
+ if(manifest.version!=='ksf.workshop.publish.v1'||manifest.commit!=='d14d02fd358c6c5dedfabc96c53fb29add8bd021'||!/^[a-f0-9]{64}$/.test(manifest.homepageSha256))fail('BAD_MANIFEST');
+ if(!Array.isArray(manifest.files)||JSON.stringify(manifest.files.map(f=>f.path).sort())!==JSON.stringify([...FILES].sort()))fail('UNEXPECTED_FILE_LIST');
+ const index=safePath(targetDir,'index.html');plain(index);const indexHash=sha(fs.readFileSync(index));
+ if(indexHash!==manifest.homepageSha256)fail('HOMEPAGE_MISMATCH','index.html');
+ const byName=new Map(manifest.files.map(f=>[f.path,f]));
+ const entries=FILES.map(relative=>{
+  const def=byName.get(relative),source=safePath(sourceDir,relative),target=safePath(targetDir,relative);plain(source);
+  if(!/^[a-f0-9]{64}$/.test(def.sha256)||!Array.isArray(def.priorNormalizedSha256)||def.priorNormalizedSha256.some(h=>!/^[a-f0-9]{64}$/.test(h)))fail('BAD_MANIFEST');
+  const bytes=fs.readFileSync(source);if(sha(bytes)!==def.sha256&&normalized(bytes)!==def.sha256)fail('PAYLOAD_HASH_MISMATCH',relative);
+  const stat=fs.existsSync(target)?plain(target):null,before=stat?fs.readFileSync(target):null;
+  const same=before!==null&&normalized(before)===normalized(bytes);
+  if(before&&!same&&!def.priorNormalizedSha256.includes(normalized(before)))fail('UNKNOWN_TARGET_CONTENT',relative);
+  if(!stat&&!def.allowMissing)fail('BASELINE_FILE_MISSING',relative);
+  return{relative,target,bytes,before,beforeHash:before===null?null:sha(before),mode:stat?stat.mode&0o777:0o644,same};
+ });
+ const changes=entries.filter(e=>!e.same);
+ const base={operation:apply?'filesystem-update':'read-only-plan',readOnly:!apply,targetDir,commit:manifest.commit,filesChecked:9,changes:changes.map(e=>e.relative),unchanged:entries.filter(e=>e.same).map(e=>e.relative),homepageSha256:indexHash,homepageModified:false,networkRequests:0,cdnCacheRefreshed:false,publicSiteVerified:false};
+ if(!apply||!changes.length)return{ok:true,...base,backup:null};
+
+ const lock=path.join(path.dirname(targetDir),'.'+path.basename(targetDir)+'.ksf-workshop.lock');
+ const lockFd=fs.openSync(lock,'wx',0o600);let backup=null;const done=[],createdDirs=[];
+ try{
+  backup=fs.mkdtempSync(path.join(path.dirname(targetDir),'ksf-workshop-backup-'));
+  const journal={at:new Date().toISOString(),targetDir,commit:manifest.commit,homepageSha256:indexHash,status:'prepared',files:changes.map(e=>({path:e.relative,beforeSha256:e.beforeHash,afterSha256:sha(e.bytes)}))};
+  const writeJournal=()=>fs.writeFileSync(path.join(backup,'journal.json'),JSON.stringify(journal,null,2),{mode:0o600});
+  // Save every prior file before replacing the first one.
+  for(const e of changes)if(e.before!==null){const p=path.join(backup,e.relative);fs.mkdirSync(path.dirname(p),{recursive:true});fs.writeFileSync(p,e.before,{flag:'wx',mode:0o600});}
+  writeJournal();
+  for(const e of changes){
+   unchanged(index,indexHash);safePath(targetDir,e.relative);unchanged(e.target,e.beforeHash);
+   const absent=[];let parent=path.dirname(e.target);while(!fs.existsSync(parent)){absent.push(parent);parent=path.dirname(parent);}
+   for(const dir of absent.reverse()){fs.mkdirSync(dir);createdDirs.push(dir);}
+   dependencies.beforeWrite?.(e.relative);
+   atomicWrite(e.target,e.bytes,e.mode);done.push(e);
+  }
+  for(const e of entries){plain(e.target);if(normalized(fs.readFileSync(e.target))!==normalized(e.bytes))fail('TARGET_VERIFY_FAILED',e.relative);}
+  unchanged(index,indexHash);journal.status='verified';writeJournal();
+  return{ok:true,...base,backup,filesystemVerified:true,publicVerificationRequired:true};
+ }catch(error){
+  let rollbackComplete=true;
+  for(const e of [...done].reverse())try{
+   // Never overwrite a concurrent publisher's version while rolling back.
+   unchanged(e.target,sha(e.bytes));
+   if(e.before===null)fs.unlinkSync(e.target);else atomicWrite(e.target,e.before,e.mode);
+  }catch{rollbackComplete=false;}
+  for(const dir of [...createdDirs].reverse())try{fs.rmdirSync(dir);}catch{}
+  if(backup)try{const p=path.join(backup,'journal.json');const j=JSON.parse(fs.readFileSync(p));fs.writeFileSync(p,JSON.stringify({...j,status:'stopped',rollbackComplete},null,2));}catch{}
+  error.backup=backup;error.rollbackComplete=rollbackComplete;throw error;
+ }finally{fs.closeSync(lockFd);fs.unlinkSync(lock);}
+}
+
+const self=fileURLToPath(import.meta.url);
+if(process.argv[1]&&path.resolve(process.argv[1])===self){
+ const stamp={invocationId:crypto.randomUUID(),at:new Date().toISOString()};
+ try{
+  const argv=process.argv.slice(2);let targetDir,apply=false;
+  for(let i=0;i<argv.length;i++){if(argv[i]==='--apply'&&!apply)apply=true;else if(argv[i]==='--target-dir'&&!targetDir&&argv[i+1]&&!argv[i+1].startsWith('--'))targetDir=argv[++i];else fail('USAGE');}
+  if(!targetDir)fail('USAGE');const root=path.resolve(path.dirname(self),'..');
+  console.log(JSON.stringify({...stamp,...publishFiles({sourceDir:root,manifestPath:path.join(root,'deploy/workshop-files-20261101.json'),targetDir,apply})},null,2));
+ }catch(e){console.error(JSON.stringify({...stamp,ok:false,error:{code:/^[A-Z][A-Z0-9_]+$/.test(e.code||'')?e.code:'PUBLISH_STOPPED',...(FILES.includes(e.file)||e.file==='index.html'?{file:e.file}:{}),message:'发布停止;核对源站目录、首页和白名单文件,不自动修改校验基准。'},...(e.backup?{backup:e.backup,rollbackComplete:e.rollbackComplete}:{})},null,2));process.exitCode=1;}
+}

+ 110 - 0
scripts/reassemble-voc-database.py

@@ -0,0 +1,110 @@
+"""Reassemble the two private transport ZIPs; never install or replace a database."""
+import argparse
+import hashlib
+import json
+import os
+from pathlib import Path
+import sys
+import tempfile
+import zipfile
+
+sys.dont_write_bytecode = True
+FILENAME = 'ksf-voc-database-20261106-r1.zip'
+SIZE = 25913256
+SHA256 = '1ba1ac2fa6a8b774f8945d88e014c07dd5e03332792af298a41f015ba6946f9f'
+PART_SIZES = [14000000, 11913256]
+
+def require(value, code):
+    if not value:
+        raise ValueError(code)
+
+def digest(p):
+    with Path(p).open('rb') as f:
+        return hashlib.file_digest(f, 'sha256').hexdigest()
+
+def regular_path(p):
+    p = Path(p)
+    require(p.is_absolute(), 'ABSOLUTE_PATH_REQUIRED')
+    for parent in (p, *p.parents):
+        require(not parent.is_symlink(), 'SYMLINK_PATH_REFUSED')
+    return p
+
+def reassemble(parts, output):
+    require(len(parts) == 2, 'EXACTLY_TWO_PARTS_REQUIRED')
+    paths = [regular_path(p) for p in parts]
+    require(len(set(paths)) == 2 and all(p.is_file() for p in paths), 'PART_MISSING_OR_DUPLICATED')
+    output = regular_path(output)
+    require(output.name == FILENAME and output.parent.is_dir(), 'INVALID_OUTPUT_PATH')
+    ordered = {}
+    common = None
+    for p in paths:
+        with zipfile.ZipFile(p) as z:
+            require(z.getinfo('TRANSPORT.json').file_size < 16000, 'INVALID_TRANSPORT_MANIFEST')
+            meta = json.loads(z.read('TRANSPORT.json'))
+            require(meta.get('filename') == FILENAME and meta.get('bytes') == SIZE and
+                    meta.get('sha256') == SHA256 and len(meta.get('parts', [])) == 2,
+                    'WRONG_SOURCE_PACKAGE')
+            require(common is None or common == meta, 'TRANSPORT_MANIFEST_MISMATCH')
+            common = meta
+            found = [i for i in (1, 2) if FILENAME + f'.part{i:02}' in z.namelist()]
+            require(len(found) == 1 and found[0] not in ordered, 'PART_MISSING_OR_DUPLICATED')
+            index = found[0]
+            entry = meta['parts'][index - 1]
+            name = FILENAME + f'.part{index:02}'
+            require(entry.get('chunk') == name and entry.get('bytes') == PART_SIZES[index - 1] and
+                    isinstance(entry.get('sha256'), str) and len(entry['sha256']) == 64 and
+                    z.getinfo(name).file_size == PART_SIZES[index - 1], 'INVALID_PART_SIZE_OR_NAME')
+            require(len(z.namelist()) == len(set(z.namelist())) == 4 and
+                    set(z.namelist()) == {name, 'TRANSPORT.json', 'reassemble-voc-database.py', 'README.md'},
+                    'UNEXPECTED_TRANSPORT_CONTENTS')
+            ordered[index] = (p, entry)
+    require(set(ordered) == {1, 2}, 'PART_MISSING_OR_DUPLICATED')
+    if output.exists():
+        require(output.is_file() and output.stat().st_size == SIZE and digest(output) == SHA256,
+                'DIFFERENT_OUTPUT_ALREADY_EXISTS')
+        return {'ok': True, 'operation': 'unchanged', 'output': str(output), 'bytes': SIZE,
+                'sha256': SHA256, 'fileCreated': False, 'installed': False, 'databaseWrites': 0}
+    fd, temporary = tempfile.mkstemp(prefix='.ksf-voc-reassemble-', dir=output.parent)
+    try:
+        total = 0
+        whole = hashlib.sha256()
+        with os.fdopen(fd, 'wb') as dest:
+            for index in (1, 2):
+                p, entry = ordered[index]
+                chunk_hash = hashlib.sha256()
+                length = 0
+                with zipfile.ZipFile(p) as z, z.open(entry['chunk']) as source:
+                    while data := source.read(1024 * 1024):
+                        length += len(data)
+                        require(length <= entry['bytes'], 'PART_TOO_LARGE')
+                        chunk_hash.update(data)
+                        whole.update(data)
+                        dest.write(data)
+                require(length == entry['bytes'] and chunk_hash.hexdigest() == entry['sha256'],
+                        'PART_HASH_MISMATCH')
+                total += length
+            require(total == SIZE and whole.hexdigest() == SHA256, 'REASSEMBLED_HASH_MISMATCH')
+            dest.flush()
+            os.fsync(dest.fileno())
+        # A same-filesystem hard link publishes the verified file without overwriting a race winner.
+        os.link(temporary, output)
+        return {'ok': True, 'operation': 'reassembled', 'output': str(output), 'bytes': total,
+                'sha256': whole.hexdigest(), 'fileCreated': True, 'installed': False, 'databaseWrites': 0}
+    finally:
+        os.unlink(temporary)
+
+def main():
+    parser = argparse.ArgumentParser(description=__doc__)
+    parser.add_argument('--part', action='append', required=True, help='Actual absolute path to each transport ZIP; repeat twice')
+    parser.add_argument('--output', required=True, help='Absolute path of the original ZIP under an existing private directory')
+    args = parser.parse_args()
+    try:
+        print(json.dumps(reassemble(args.part, args.output), ensure_ascii=False, indent=2))
+    except Exception as exc:
+        code = str(exc) if isinstance(exc, ValueError) else 'REASSEMBLY_STOPPED'
+        print(json.dumps({'ok': False, 'error': {'code': code}, 'installed': False, 'databaseWrites': 0}))
+        return 1
+    return 0
+
+if __name__ == '__main__':
+    raise SystemExit(main())

+ 169 - 0
scripts/simulate-S4-classroom-next.mjs

@@ -0,0 +1,169 @@
+// Executes the captured production handler definitions against local in-memory
+// storage only. No platform-client import and no network or production writes.
+import fs from 'node:fs';
+import vm from 'node:vm';
+import crypto from 'node:crypto';
+import assert from 'node:assert/strict';
+import {runWorkshop} from '../skills/ksf-workshop-runtime/scripts/ksf-workshop.mjs';
+
+const output='work/S4-classroom-isolated-simulation-20261103-02.json';
+assert.equal(fs.existsSync(output),false,'Preserve prior simulation');
+const base='work/S4-classroom-next-20261103/';
+const audit=JSON.parse(fs.readFileSync('work/S4-classroom-contract-check-20261103-03.json','utf8'));
+assert.equal(audit.ok,true);
+const sha=value=>crypto.createHash('sha256').update(value).digest('hex');
+const copy=structuredClone;
+const proof=[],calls=[],checks=[];
+const okay=(name,fn)=>{fn();checks.push({name,passed:true});};
+
+function readEmbeddedData(code,name){
+ const marker='runDatabaseFunction('+JSON.stringify(name)+',request,response,';
+ const at=code.lastIndexOf(marker)+marker.length;assert.ok(at>=marker.length);
+ let depth=0,quoted=false,escaped=false;
+ for(let i=at;i<code.length;i++){
+  const ch=code[i];
+  if(quoted){if(escaped)escaped=false;else if(ch==='\\')escaped=true;else if(ch==='"')quoted=false;continue;}
+  if(ch==='"')quoted=true;
+  else if(ch==='{')depth++;
+  else if(ch==='}'&&--depth===0)return JSON.parse(code.slice(at,i+1));
+ }
+ throw Error('Embedded data missing');
+}
+const modules=new Map();
+let data;
+for(const name of ['ksfSaveWorkshopTask','ksfCreateProjectFromOpportunity','ksfTransitionProject','ksfGetWorkshopProject']){
+ const code=fs.readFileSync('work/S4-deployed-'+name+'-20261103.js','utf8');
+ const expected=audit.deployedCodeChecks.find(x=>x.name===name);assert.equal(sha(code),expected.sha256);
+ const runtime=readEmbeddedData(code,name);
+ if(data)assert.deepEqual(runtime,data);else data=runtime;
+ const handlers=vm.runInNewContext(code+'\n;({createApplication,attachWorkshopHandlers});',{
+  Buffer,structuredClone,URL,URLSearchParams,TextEncoder,TextDecoder
+ },{timeout:5000});
+ modules.set(name,handlers);proof.push({name,sha256:sha(code)});
+}
+const actor={id:'isolated-service-only',kind:'service',scopes:['ingest:write','data:read','source:write'],projectIds:['*']};
+const attribution={agentKey:'agent-registration:11111111-1111-4111-8111-111111111111',agentName:'隔离验证助手',agentKeyOrigin:'runtime_config',
+ participantKey:'isolated-participant',participantName:'隔离验证参与者',channel:'wecom',agentRuntimePlatform:'hermes',agentChannel:'wecom'};
+const agentProfile={key:attribution.agentKey,name:attribution.agentName,identityBasis:'runtime_config',role:'workshop',runtimePlatform:'hermes',channel:'wecom'};
+const db=new Map(),copyRow=value=>value==null?null:copy(value);
+let sequence=0;
+const rows=name=>{if(!db.has(name))db.set(name,[]);return db.get(name);};
+const equal=(a,b)=>JSON.stringify(a??null)===JSON.stringify(b??null);
+const store={
+ async transaction(fn){const before=copy(db);try{return await fn(this);}catch(error){db.clear();for(const [k,v]of before)db.set(k,v);throw error;}},
+ async lock(){},
+ async get(name,id){return copyRow(rows(name).find(x=>x.objectId===id));},
+ async list(name,{where={},afterId,limit=1000}={}){return copy(rows(name).filter(row=>Object.entries(where).every(([k,v])=>equal(row[k],v))).filter(row=>!afterId||row.objectId>afterId).sort((a,b)=>a.objectId.localeCompare(b.objectId)).slice(0,limit));},
+ async create(name,input){for(const key of Object.keys(input))assert.ok(data.tables[name][key],name+'.'+key);
+  const row={...copy(input),objectId:'sim'+String(++sequence).padStart(7,'0'),createdAt:new Date().toISOString(),updatedAt:new Date().toISOString()};rows(name).push(row);return copy(row);},
+ async update(name,id,input){for(const key of Object.keys(input))assert.ok(data.tables[name][key],name+'.'+key);
+  const row=rows(name).find(x=>x.objectId===id);assert.ok(row);Object.assign(row,copy(input),{updatedAt:new Date().toISOString()});return copy(row);}
+};
+// Seed a synthetic actor/session and accepted original source text only in RAM.
+// The null ID fields of the delivered templates are the only mutated inputs.
+rows('KfSession').push({objectId:'simSession01',dataMode:'live',workshopKey:'KSF-真实资料-20261102-01',stageCode:'S3',studentName:attribution.participantName,groupId:'',meta:{ownerId:actor.id,attribution}});
+const publicRows=JSON.parse(fs.readFileSync('work/S4-public-record-inspection-20261103.json','utf8')).records;
+for(const row of publicRows.filter(x=>x.className==='KfSource'))rows('KfSource').push({...copy(row.record),meta:{ownerId:actor.id}});
+const previous=JSON.parse(fs.readFileSync('work/first-live-S3-v2-template.json','utf8'));
+const originalIds={'consumer-cq-03':'cq-03','consumer-cq-04':'cq-04'};
+for(const [key,id]of Object.entries(originalIds)){
+ const input=JSON.parse(fs.readFileSync('work/consumer-quote-inputs-20261102/'+id+'.input.json','utf8'));
+ rows('KfSource').push({...copy(input.source),objectId:previous.report.evidence[key].sourceId,meta:{ownerId:actor.id}});
+}
+const beforeSourceRows=copy(rows('KfSource'));
+const apps=new Map();
+for(const [name,impl]of modules){
+ const app=impl.createApplication({store,crypto,Parse:{},data,settings:{signingSecret:'isolated-simulation-only'},files:{}});
+ impl.attachWorkshopHandlers(app,{store,data});apps.set(name,app);
+}
+const general=apps.get('ksfSaveWorkshopTask');
+async function call(name,params){
+ const app=apps.get(name)||general;
+ const result=await app.dispatch(name,copy(params),actor);
+ return copy(result.data);
+}
+async function execute(command,input,requestId){
+ const result=await runWorkshop(command,copy(input),{requestId,agentProfile,call});
+ calls.push({command,requestId,ok:true});return result;
+}
+const getTemplate=name=>JSON.parse(fs.readFileSync(base+name,'utf8'));
+const bound=(name,ids)=>{const input=getTemplate(name),result={...copy(input),...ids};for(const key of Object.keys(ids))assert.equal(input[key],null);return result;};
+const prefix='KSF-真实资料-20261102-01',sessionId='simSession01';
+function verifyReport(row,template,projectId=null){
+ assert.equal(row.reportType,'workshop_auxiliary');assert.equal(row.artifactSubtype,'classroom_task');assert.equal(row.artifactType,template.artifactType);
+ for(const field of ['rawText','version','stageCode','reportSeriesKey'])assert.equal(row[field],field==='reportSeriesKey'?template.taskKey:template[field]);
+ assert.deepEqual(row.meta.workshop.sourceIds,template.sourceIds);assert.deepEqual(row.meta.workshop.pendingQuestions,template.pendingQuestions);
+ assert.equal(row.evidenceCount,4);assert.equal(row.ownerSession,sessionId);assert.equal(row.projectId??null,projectId);
+ for(const field of ['chapters','evidence','method','sources','subtitle'])assert.deepEqual(row.report[field],template.report[field]);
+ assert.deepEqual(row.report.executive.slice(1),template.report.executive);assert.equal(row.report.executive[0],'课堂过程成果,未经员工正式确认;待验证项不作为市场结论。');
+}
+let step='candidate save/read';
+try{
+ const candidate=bound('01-candidate-save.template.json',{sessionId});
+ const c=await execute('workshop-save',candidate,prefix+':S3-份量候选:save:v1');
+ const artifact=(await execute('workshop-read',{className:'KfArtifact',objectId:c.artifactId},prefix+':S3-份量候选:read:v1')).record;
+ okay('candidate report stored as workshop_auxiliary/classroom_task with opportunity_card artifactType',()=>verifyReport(artifact,candidate));
+ const opp=(await execute('workshop-read',{className:'KfOpportunity',objectId:c.opportunityId},prefix+':portion-fit:read:v1')).record;
+ okay('opportunity handler supplies hypothesis/candidate/version/series and report link',()=>{
+  assert.equal(opp.claimKind,'hypothesis');assert.equal(opp.status,'candidate');assert.equal(opp.version,1);
+  assert.equal(opp.opportunitySeriesKey,candidate.opportunity.key);assert.deepEqual(opp.linkedArtifacts,[c.artifactId]);
+ });
+ step='project create/status';
+ const p=await execute('project-create',bound('02-project-create.template.json',{sessionId,opportunityId:c.opportunityId}),prefix+':portion-fit-project:create:v1');
+ const projectId=p.projectId,statusInput={sessionId,projectId};
+ const created=await execute('project-status',statusInput,prefix+':portion-fit-project:read-created:v1');
+ okay('projectKey stored in meta, stage S3, no project artifacts, no enterprise approval',()=>{
+  assert.equal(created.project.meta.projectKey,getTemplate('02-project-create.template.json').projectKey);
+  assert.equal(created.project.stageCode,'S3');assert.equal(created.artifacts.length,0);assert.equal(p.enterpriseApproval,false);
+ });
+ okay('classroom project changes opportunity to approved with linked project only',()=>{
+  const saved=rows('KfOpportunity').find(x=>x.objectId===c.opportunityId);assert.equal(saved.status,'approved');assert.equal(saved.linkedProject,projectId);
+ });
+ // Negative branch is local-only and uses a simulation-specific request ID.
+ step='negative control: refuse premature transition';
+ let premature;
+ try{await call('ksfTransitionProject',{sessionId,projectId,toStage:'S4',state:'in_progress',note:'negative control only',requestId:'isolated-premature-S4'});}catch(error){premature=error.code;}
+ okay('S3 project artifact is necessary before entering S4',()=>assert.equal(premature,'STAGE_REQUIREMENTS_NOT_MET'));
+ step='project S3 baseline/save/read';
+ const s3input=bound('03-project-S3-save.template.json',{sessionId,projectId});
+ const s3=await execute('workshop-save',s3input,prefix+':S3-项目基线:save:v1');
+ const s3row=(await execute('workshop-read',{className:'KfArtifact',objectId:s3.artifactId},prefix+':S3-项目基线:read:v1')).record;
+ okay('project S3 baseline keeps four quotes/eight questions and actual project reference',()=>verifyReport(s3row,s3input,projectId));
+ step='transition/status';
+ const moved=await execute('project-progress',bound('04-project-S4-progress.template.json',{sessionId,projectId}),prefix+':portion-fit-project:to-S4:v1');
+ const underway=await execute('project-status',statusInput,prefix+':portion-fit-project:read-S4:v1');
+ okay('project transition returns S4/in_progress and preserves S3 baseline',()=>{
+  assert.equal(moved.stageCode,'S4');assert.equal(moved.progressState,'in_progress');assert.equal(moved.enterpriseApproval,false);
+  assert.equal(underway.project.stageCode,'S4');assert.equal(underway.artifacts.length,1);
+ });
+ step='project S4 assessment/save/read';
+ const s4input=bound('05-project-S4-save.template.json',{sessionId,projectId});
+ const s4=await execute('workshop-save',s4input,prefix+':S4:save:v1');
+ const s4row=(await execute('workshop-read',{className:'KfArtifact',objectId:s4.artifactId},prefix+':S4:read:v1')).record;
+ okay('S4 assessment_report persists as artifactType alongside workshop_auxiliary reportType',()=>verifyReport(s4row,s4input,projectId));
+ const final=await execute('project-status',statusInput,prefix+':portion-fit-project:read-final:v1');
+ okay('final project remains S4/in_progress with S3/S4 reports and no concept/formula',()=>{
+  assert.equal(final.project.stageCode,'S4');assert.equal(final.project.progressState,'in_progress');assert.equal(final.artifacts.length,2);
+  assert.equal(final.concept,null);assert.equal(final.formulaItems.length,0);assert.equal(final.events.length,4);
+ });
+ okay('12 ordered commands use each delivered request ID exactly once',()=>{
+  assert.equal(calls.length,12);assert.deepEqual(calls.map(x=>x.requestId),audit.checkedRequestIds);
+ });
+ okay('only three reports/sources, one candidate/project and five write audit records created in RAM',()=>{
+  assert.equal(rows('KfArtifact').length,3);assert.equal(rows('KfOpportunity').length,1);assert.equal(rows('KfProject').length,1);assert.equal(rows('KsfOperation').length,5);
+  assert.equal(rows('KfSource').length-beforeSourceRows.length,3);
+  assert.equal(rows('KfProduct').length,0);assert.equal(rows('KfCategory').length,0);assert.equal(rows('KfConcept').length,0);
+  assert.deepEqual(rows('KfSource').filter(x=>beforeSourceRows.some(y=>x.objectId===y.objectId)),beforeSourceRows);
+ });
+ const result={ok:true,at:new Date().toISOString(),scope:'Local RAM simulation using captured deployed command source and runtime schema; synthetic execution identity/session',
+  productionCalls:0,productionWrites:0,browserVerified:false,templatesModified:false,checksPassed:checks.length,checks,calls,
+  deployedCode:proof,simulatedOnly:true,negativeControl:'Premature S4 transition rejected before project S3 baseline',
+  threeReportedDifferencesResolved:true,expectedFinal:{stageCode:'S4',progressState:'in_progress',reports:3,projectReports:2,opportunities:1,projects:1},
+  limitations:['Does not exercise real platform database transaction/transport or current-message identity integration','Production writes/readbacks remain necessary; stop on uncertainty']};
+ fs.writeFileSync(output,JSON.stringify(result,null,2)+'\n',{flag:'wx'});
+ console.log(JSON.stringify({ok:true,checksPassed:checks.length,orderedCommandCount:calls.length,productionCalls:0,productionWrites:0,simulatedOnly:true,negativeControl:result.negativeControl,expectedFinal:result.expectedFinal},null,2));
+}catch(error){
+ const result={ok:false,step,errorCode:'S4_CLASSROOM_SIMULATION_STOPPED',failureCode:error.code??null,checkLine:error.stack?.match(/simulate-S4-classroom-next\.mjs:(\d+):/)?.[1]??null,
+  commandsCompleted:calls.length,checksPassed:checks.length,productionCalls:0,productionWrites:0,simulatedOnly:true};
+ fs.writeFileSync(output,JSON.stringify(result,null,2)+'\n',{flag:'wx'});console.error(JSON.stringify(result,null,2));process.exitCode=1;
+}

+ 134 - 0
scripts/simulate-S5-classroom-next.mjs

@@ -0,0 +1,134 @@
+// Captured deployed code, synthetic actor/session and RAM only. No network.
+import fs from 'node:fs';
+import vm from 'node:vm';
+import crypto from 'node:crypto';
+import assert from 'node:assert/strict';
+import {runWorkshop} from '../skills/ksf-workshop-runtime/scripts/ksf-workshop.mjs';
+const output='work/S5-classroom-isolated-simulation-20261103.json';assert.equal(fs.existsSync(output),false);
+const folder='work/S5-classroom-next-20261103/';
+const audit=JSON.parse(fs.readFileSync('work/S5-classroom-live-preflight-20261103.json','utf8'));assert.equal(audit.ok,true);
+const runbook=JSON.parse(fs.readFileSync(folder+'runbook.json','utf8'));
+const tasks=Object.fromEntries(['01-project-S5-progress.template.json','02-concept-save.template.json','03-project-S5-save.template.json'].map(n=>[n,JSON.parse(fs.readFileSync(folder+n,'utf8'))]));
+const sha=x=>crypto.createHash('sha256').update(x).digest('hex');
+const copy=structuredClone;
+const checks=[],calls=[],proof=[];let step='load captured definitions';
+const check=(name,fn)=>{step=name;fn();checks.push({name,passed:true});};
+function embedded(code,name){
+ const marker='runDatabaseFunction('+JSON.stringify(name)+',request,response,',at=code.lastIndexOf(marker)+marker.length;assert.ok(at>=marker.length);
+ let depth=0,quoted=false,escaped=false;
+ for(let i=at;i<code.length;i++){
+  const c=code[i];if(quoted){if(escaped)escaped=false;else if(c==='\\')escaped=true;else if(c==='"')quoted=false;continue;}
+  if(c==='"')quoted=true;else if(c==='{')depth++;else if(c==='}'&&--depth===0)return JSON.parse(code.slice(at,i+1));
+ }
+ throw Error('Embedded schema missing');
+}
+const modules=new Map();let data;
+for(const snapshot of audit.deployedCodeChecks){
+ const code=fs.readFileSync(snapshot.file,'utf8');assert.equal(sha(code),snapshot.sha256);
+ const runtime=embedded(code,snapshot.name);if(data)assert.deepEqual(runtime,data);else data=runtime;
+ const impl=vm.runInNewContext(code+'\n;({createApplication,attachWorkshopHandlers});',{Buffer,structuredClone,URL,URLSearchParams,TextEncoder,TextDecoder},{timeout:5000});
+ modules.set(snapshot.name,impl);proof.push({name:snapshot.name,sha256:snapshot.sha256});
+}
+const actor={id:'isolated-s5-service',kind:'service',scopes:['ingest:write','data:read','source:write'],projectIds:['*']};
+const attribution={agentKey:'agent-registration:11111111-1111-4111-8111-111111111111',agentName:'隔离模拟助手',agentKeyOrigin:'runtime_config',
+ participantKey:'isolated-s5-participant',participantName:'隔离模拟参与者',channel:'wecom',agentRuntimePlatform:'hermes',agentChannel:'wecom'};
+const agentProfile={key:attribution.agentKey,name:attribution.agentName,identityBasis:'runtime_config',role:'workshop',runtimePlatform:'hermes',channel:'wecom'};
+const db=new Map(),rows=n=>{if(!db.has(n))db.set(n,[]);return db.get(n);};let sequence=0;
+const equal=(a,b)=>JSON.stringify(a??null)===JSON.stringify(b??null),ptr=(className,objectId)=>({__type:'Pointer',className,objectId});
+const store={
+ async transaction(fn){const before=copy(db);try{return await fn(this);}catch(e){db.clear();for(const [k,v]of before)db.set(k,v);throw e;}},
+ async lock(){},async get(name,id){return copy(rows(name).find(x=>x.objectId===id)??null);},
+ async list(name,{where={},afterId,limit=1000}={}){return copy(rows(name).filter(r=>Object.entries(where).every(([k,v])=>equal(r[k],v))).filter(r=>!afterId||r.objectId>afterId).sort((a,b)=>a.objectId.localeCompare(b.objectId)).slice(0,limit));},
+ async create(name,input){for(const key of Object.keys(input))assert.ok(data.tables[name][key],name+'.'+key);const r={...copy(input),objectId:'simS5'+String(++sequence).padStart(5,'0'),createdAt:new Date().toISOString(),updatedAt:new Date().toISOString()};rows(name).push(r);return copy(r);},
+ async update(name,id,input){for(const key of Object.keys(input))assert.ok(data.tables[name][key],name+'.'+key);const r=rows(name).find(x=>x.objectId===id);assert.ok(r);Object.assign(r,copy(input),{updatedAt:new Date().toISOString()});return copy(r);}
+};
+const sessionId='simS5Session',projectId='simS5Project',baselineId='simS3Baseline',s4Id='simS4Assessment';
+rows('KfSession').push({objectId:sessionId,dataMode:'live',workshopKey:'KSF-真实资料-20261102-01',stageCode:'S4',studentName:attribution.participantName,groupId:'',meta:{ownerId:actor.id,attribution}});
+rows('KfProject').push({objectId:projectId,dataMode:'live',workshopKey:'KSF-真实资料-20261102-01',stageCode:'S4',stage:'S4 机会验证评估',status:'workshop',progressState:'in_progress',
+ ownerSession:sessionId,sessionRef:ptr('KfSession',sessionId),artifactCount:2,groupId:'',meta:{ownerId:actor.id,participantKey:attribution.participantKey,pendingQuestions:[]}});
+rows('KfArtifact').push({objectId:baselineId,stageCode:'S3',projectRef:ptr('KfProject',projectId)},{objectId:s4Id,stageCode:'S4',projectRef:ptr('KfProject',projectId)});
+for(const [i,type]of ['project_created','task_submitted','stage_changed','task_submitted'].entries())rows('KfStageEvent').push({objectId:'simOldEvent'+i,eventType:type,sequence:i+1,projectRef:ptr('KfProject',projectId)});
+const publicRows=JSON.parse(fs.readFileSync('work/S4-public-record-inspection-20261103.json','utf8')).records;
+for(const r of publicRows.filter(r=>r.className==='KfSource'))rows('KfSource').push({...copy(r.record),meta:{ownerId:actor.id}});
+const prior=JSON.parse(fs.readFileSync('work/first-live-S3-v2-template.json','utf8'));
+for(const [key,file]of [['consumer-cq-03','cq-03'],['consumer-cq-04','cq-04']]){
+ const input=JSON.parse(fs.readFileSync('work/consumer-quote-inputs-20261102/'+file+'.input.json','utf8'));
+ rows('KfSource').push({...copy(input.source),objectId:prior.report.evidence[key].sourceId,meta:{ownerId:actor.id}});
+}
+const originalSources=copy(rows('KfSource')),originalArtifacts=copy(rows('KfArtifact'));
+const apps=new Map();
+for(const [name,impl]of modules){const app=impl.createApplication({store,crypto,Parse:{},data,settings:{signingSecret:'isolated-s5-only'},files:{}});impl.attachWorkshopHandlers(app,{store,data});apps.set(name,app);}
+const call=async(name,params)=>copy((await (apps.get(name)||apps.get('ksfSaveWorkshopTask')).dispatch(name,copy(params),actor)).data);
+const execute=async(i,input)=>{const s=runbook.steps[i],r=await runWorkshop(s.command,copy(input),{requestId:s.requestId,agentProfile,call});calls.push({step:s.step,command:s.command,requestId:s.requestId,ok:true});return r;};
+const bind=name=>{const t=tasks[name];assert.equal(t.sessionId,null);assert.equal(t.projectId,null);return {...copy(t),sessionId,projectId};};
+const statusInput={sessionId,projectId};
+try{
+ step='negative: no S4 artifact';
+ rows('KfArtifact').splice(1,1);let missing;
+ try{await call('ksfTransitionProject',{...statusInput,requestId:'isolated-s5-missing-baseline',toStage:'S5',state:'in_progress',note:'local negative control'});}catch(e){missing=e.code;}
+ check('missing project S4 report prevents transition with no side effects',()=>{
+  assert.equal(missing,'STAGE_REQUIREMENTS_NOT_MET');assert.equal(rows('KfProject')[0].stageCode,'S4');assert.equal(rows('KfStageEvent').length,4);assert.equal(rows('KsfOperation').length,0);
+ });
+ rows('KfArtifact').push(copy(originalArtifacts[1]));
+ const before=await execute(0,statusInput);
+ check('initial S4 status matches precondition',()=>{assert.equal(before.project.stageCode,'S4');assert.equal(before.artifacts.length,2);assert.equal(before.events.length,4);assert.equal(before.concept,null);});
+ const move=await execute(1,bind('01-project-S5-progress.template.json'));
+ const moved=await execute(2,statusInput);
+ check('S5 in progress transition adds only one stage event',()=>{
+  assert.equal(move.stageCode,'S5');assert.equal(move.progressState,'in_progress');assert.equal(move.enterpriseApproval,false);
+  assert.equal(moved.project.stageCode,'S5');assert.equal(moved.events.length,5);assert.equal(moved.artifacts.length,2);assert.equal(moved.concept,null);
+ });
+ step='negative: reject skipped initial concept version';let skipped;
+ try{await call('ksfSaveWorkshopConcept',{...bind('02-concept-save.template.json'),version:2,requestId:'isolated-s5-skipped-version'});}catch(e){skipped=e.code;}
+ check('first concept must be v1; rejected version creates no partial concept',()=>{assert.equal(skipped,'VERSION_CONFLICT');assert.equal(rows('KfConcept').length,0);assert.equal(rows('KfStageEvent').length,5);});
+ const c=await execute(3,bind('02-concept-save.template.json'));
+ const conceptStatus=await execute(4,statusInput),t=tasks['02-concept-save.template.json'];
+ check('concept v1 preserves explicit unknowns, empty arrays and 13 project questions',()=>{
+  assert.equal(c.version,1);assert.equal(c.enterpriseApproval,false);assert.deepEqual(c.formulaItemIds,[]);assert.deepEqual(c.pendingQuestions,t.concept.pendingQuestions);
+  assert.equal(conceptStatus.concept.objectId,c.conceptId);assert.equal(conceptStatus.concept.version,1);assert.equal(conceptStatus.concept.dataMode,'live');
+  assert.deepEqual(conceptStatus.concept.projectRef,ptr('KfProject',projectId));assert.deepEqual(conceptStatus.project.currentConceptRef,ptr('KfConcept',c.conceptId));
+  for(const k of ['name','targetAudience','usageScenario','specification','valueProposition','flavor','sellingPoints','productionSteps'])assert.deepEqual(conceptStatus.concept[k],t.concept[k]);
+  assert.deepEqual(conceptStatus.project.meta.pendingQuestions,t.concept.pendingQuestions);assert.equal('pendingQuestions' in conceptStatus.concept,false);
+  assert.deepEqual(conceptStatus.formulaItems,[]);assert.equal(conceptStatus.events.length,6);assert.equal(conceptStatus.events[5].eventType,'concept_saved');
+ });
+ const reportInput=bind('03-project-S5-save.template.json'),saved=await execute(5,reportInput);
+ const record=(await execute(6,{className:'KfArtifact',objectId:saved.artifactId})).record;
+ check('S5 report body, four quotes, five tables and pending questions persist',()=>{
+  assert.equal(saved.artifactId,record.objectId);assert.equal(saved.projectId,projectId);assert.equal(saved.opportunityId,null);assert.equal(saved.boardVisible,true);
+  assert.equal(saved.formalReportPublished,false);assert.equal(saved.employeeConfirmed,false);
+  assert.equal(record.artifactType,'concept');assert.equal(record.reportType,'workshop_auxiliary');assert.equal(record.artifactSubtype,'classroom_task');
+  assert.equal(record.rawText,reportInput.rawText);assert.equal(record.reportSeriesKey,reportInput.taskKey);assert.equal(record.stageCode,'S5');assert.equal(record.version,1);assert.equal(record.evidenceCount,4);
+  assert.deepEqual(record.projectRef,ptr('KfProject',projectId));assert.equal(record.projectId,projectId);assert.deepEqual(record.meta.workshop.sourceIds,reportInput.sourceIds);
+  assert.deepEqual(record.meta.workshop.pendingQuestions,reportInput.pendingQuestions);
+  for(const k of ['chapters','evidence','method','sources','subtitle'])assert.deepEqual(record.report[k],reportInput.report[k]);
+  assert.deepEqual(record.report.executive.slice(1),reportInput.report.executive);assert.equal(record.report.executive[0],'课堂过程成果,未经员工正式确认;待验证项不作为市场结论。');
+  assert.equal('conceptRef' in record,false);assert.equal(rows('KfSource').find(s=>s.objectId===saved.sourceId).rawText,reportInput.rawText);
+ });
+ const final=await execute(7,statusInput);
+ check('final S5 in progress, three reports, one concept and seven ordered events',()=>{
+  assert.equal(final.project.stageCode,'S5');assert.equal(final.project.progressState,'in_progress');assert.equal(final.project.artifactCount,3);
+  assert.equal(final.artifacts.length,3);assert.equal(final.concept.objectId,c.conceptId);assert.equal(final.concept.version,1);assert.equal(final.formulaItems.length,0);
+  assert.deepEqual(final.project.meta.pendingQuestions,reportInput.pendingQuestions);
+  assert.deepEqual(final.events.map(e=>e.eventType),['project_created','task_submitted','stage_changed','task_submitted','stage_changed','concept_saved','task_submitted']);
+  assert.deepEqual(final.events.map(e=>e.sequence),[1,2,3,4,5,6,7]);
+ });
+ check('eight commands, three writes and no duplicate entities or old record changes',()=>{
+  assert.equal(calls.length,8);assert.deepEqual(calls.map(c=>c.requestId),runbook.steps.map(s=>s.requestId));
+  assert.equal(rows('KsfOperation').length,3);assert.equal(rows('KfConcept').length,1);assert.equal(rows('KfProject').length,1);
+  assert.equal(rows('KfSource').length,originalSources.length+1);assert.equal(rows('KfArtifact').length,3);
+  assert.deepEqual(rows('KfArtifact').filter(a=>originalArtifacts.some(o=>o.objectId===a.objectId)),originalArtifacts);
+  assert.deepEqual(rows('KfSource').filter(s=>originalSources.some(o=>o.objectId===s.objectId)),originalSources);
+  for(const cls of ['KfFormulaItem','KfProduct','KfSku','KfMaterial','KfProcess','KfOpportunity'])assert.equal(rows(cls).length,0);
+ });
+ const result={ok:true,at:new Date().toISOString(),scope:'Local RAM only, current captured deployed handler code/runtime schema, synthetic identity/session/project and accepted source text',
+  simulatedOnly:true,productionCalls:0,productionWrites:0,checksPassed:checks.length,checks,calls,deployedCode:proof,templatesModified:false,browserVerified:false,
+  negativeControls:['S5 refused without project S4 artifact','Initial concept version 2 refused without partial write'],expectedFinal:runbook.expectedFinal,
+  limits:['No current-message identity or real platform database transport/transaction test.','Production readbacks remain required; stop rules apply.']};
+ fs.writeFileSync(output,JSON.stringify(result,null,2)+'\n',{flag:'wx'});
+ console.log(JSON.stringify({ok:true,checksPassed:checks.length,orderedCommandCount:calls.length,negativeControls:2,productionCalls:0,productionWrites:0,expectedFinal:runbook.expectedFinal},null,2));
+}catch(error){
+ const result={ok:false,at:new Date().toISOString(),step,errorCode:'S5_SIMULATION_STOPPED',failureCode:error.code??null,
+  checkLine:error.stack?.match(/simulate-S5-classroom-next\.mjs:(\d+):/)?.[1]??null,commandsCompleted:calls.length,checksPassed:checks.length,
+  productionCalls:0,productionWrites:0,simulatedOnly:true};
+ fs.writeFileSync(output,JSON.stringify(result,null,2)+'\n',{flag:'wx'});console.error(JSON.stringify(result,null,2));process.exitCode=1;
+}

+ 76 - 0
scripts/simulate-S5-completion.mjs

@@ -0,0 +1,76 @@
+// Captured deployed handlers in RAM only. No imports of the production client.
+import fs from 'node:fs';
+import vm from 'node:vm';
+import crypto from 'node:crypto';
+import assert from 'node:assert/strict';
+import {runWorkshop} from '../skills/ksf-workshop-runtime/scripts/ksf-workshop.mjs';
+const output='work/S5-completion-isolated-simulation-20261103.json';assert.equal(fs.existsSync(output),false);
+const dir='work/S5-classroom-completion-20261103/';
+const rb=JSON.parse(fs.readFileSync(dir+'runbook.json','utf8'));
+const template=JSON.parse(fs.readFileSync(dir+'01-project-complete.template.json','utf8'));
+const audit=JSON.parse(fs.readFileSync('work/S5-completion-live-preflight-20261103.json','utf8'));assert.equal(audit.ok,true);
+const sha=x=>crypto.createHash('sha256').update(x).digest('hex'),copy=structuredClone;
+function embedded(code,name){
+ const marker='runDatabaseFunction('+JSON.stringify(name)+',request,response,',at=code.lastIndexOf(marker)+marker.length;assert.ok(at>=marker.length);
+ let depth=0,quoted=false,escaped=false;
+ for(let i=at;i<code.length;i++){const c=code[i];if(quoted){if(escaped)escaped=false;else if(c==='\\')escaped=true;else if(c==='"')quoted=false;continue;}
+  if(c==='"')quoted=true;else if(c==='{')depth++;else if(c==='}'&&--depth===0)return JSON.parse(code.slice(at,i+1));}
+ throw Error('Missing embedded runtime schema');
+}
+const modules=new Map(),proof=[];let data,step='load captured deployed code';
+for(const s of audit.deployedCodeChecks){const code=fs.readFileSync(s.file,'utf8');assert.equal(sha(code),s.sha256);const runtime=embedded(code,s.name);if(data)assert.deepEqual(runtime,data);else data=runtime;
+ modules.set(s.name,vm.runInNewContext(code+'\n;({createApplication,attachWorkshopHandlers});',{Buffer,structuredClone,URL,URLSearchParams,TextEncoder,TextDecoder},{timeout:5000}));proof.push({name:s.name,sha256:s.sha256});}
+const actor={id:'isolated-completion-service',kind:'service',scopes:['ingest:write','data:read'],projectIds:['*']};
+const attribution={agentKey:'agent-account:'+'1'.repeat(64),agentName:'隔离模拟助手',agentKeyOrigin:'runtime_config',participantKey:'isolated-completion-participant',participantName:'隔离模拟参与者',channel:'wecom',agentRuntimePlatform:'hermes',agentChannel:'wecom'};
+const agentProfile={key:attribution.agentKey,name:attribution.agentName,identityBasis:'runtime_config',role:'workshop',runtimePlatform:'hermes',channel:'wecom'};
+const sessionId='simCompletionSession',projectId='simCompletionProject',conceptId='simCompletionConcept',ptr=(className,objectId)=>({__type:'Pointer',className,objectId});
+const db=new Map(),rows=n=>{if(!db.has(n))db.set(n,[]);return db.get(n);};let serial=0;
+const equal=(a,b)=>JSON.stringify(a??null)===JSON.stringify(b??null);
+const store={
+ async transaction(fn){const before=copy(db);try{return await fn(this);}catch(e){db.clear();for(const [k,v]of before)db.set(k,v);throw e;}},
+ async lock(){},async get(name,id){return copy(rows(name).find(r=>r.objectId===id)??null);},
+ async list(name,{where={},afterId,limit=1000}={}){return copy(rows(name).filter(r=>Object.entries(where).every(([k,v])=>equal(r[k],v))).filter(r=>!afterId||r.objectId>afterId).sort((a,b)=>a.objectId.localeCompare(b.objectId)).slice(0,limit));},
+ async create(name,input){for(const k of Object.keys(input))assert.ok(data.tables[name][k],name+'.'+k);const r={...copy(input),objectId:'simComplete'+String(++serial).padStart(5,'0'),createdAt:new Date().toISOString(),updatedAt:new Date().toISOString()};rows(name).push(r);return copy(r);},
+ async update(name,id,input){for(const k of Object.keys(input))assert.ok(data.tables[name][k],name+'.'+k);const r=rows(name).find(x=>x.objectId===id);assert.ok(r);Object.assign(r,copy(input),{updatedAt:new Date().toISOString()});return copy(r);}
+};
+rows('KfSession').push({objectId:sessionId,dataMode:'live',workshopKey:'isolated-completion-workshop',stageCode:'S5',groupId:'',meta:{ownerId:actor.id,attribution}});
+rows('KfProject').push({objectId:projectId,dataMode:'live',workshopKey:'isolated-completion-workshop',stageCode:'S5',stage:'S5 场景与卖点设计',status:'workshop',progressState:'in_progress',ownerSession:sessionId,sessionRef:ptr('KfSession',sessionId),currentConceptRef:ptr('KfConcept',conceptId),artifactCount:3,groupId:'',meta:{ownerId:actor.id,participantKey:attribution.participantKey,attribution,pendingQuestions:copy(rb.pendingQuestions)}});
+rows('KfConcept').push({objectId:conceptId,projectRef:ptr('KfProject',projectId),version:1,reviewState:'pending',specification:{netWeightG:null,noodleWeightG:null,packCount:null,currentSku:null},productionSteps:[]});
+for(const [i,s]of ['S3','S4','S5'].entries())rows('KfArtifact').push({objectId:'simReport'+i,stageCode:s,projectRef:ptr('KfProject',projectId),version:1,rawText:'unchanged report '+s,meta:{workshop:{pendingQuestions:copy(i===2?rb.pendingQuestions:rb.pendingQuestions.slice(0,8)),formalConfirmation:false}},dashboardPublishedAt:null});
+for(const [i,t]of ['project_created','task_submitted','stage_changed','task_submitted','stage_changed','concept_saved','task_submitted'].entries())rows('KfStageEvent').push({objectId:'simOldEvent'+i,eventType:t,sequence:i+1,projectRef:ptr('KfProject',projectId)});
+const initial=copy(db),reset=()=>{db.clear();for(const [k,v]of copy(initial))db.set(k,v);};
+const apps=new Map();for(const [name,impl]of modules){const app=impl.createApplication({store,crypto,Parse:{},data,settings:{},files:{}});impl.attachWorkshopHandlers(app,{store,data});apps.set(name,app);}
+const call=async(name,params)=>copy((await (apps.get(name)||apps.get('ksfTransitionProject')).dispatch(name,copy(params),actor)).data);
+const input={...copy(template),sessionId,projectId},statusInput={sessionId,projectId};
+const checks=[],calls=[];const check=(name,fn)=>{step=name;fn();checks.push({name,passed:true});};
+const execute=async(i,task)=>{const s=rb.steps[i];const result=await runWorkshop(s.command,copy(task),{requestId:s.requestId,agentProfile,call});calls.push({step:s.step,command:s.command,requestId:s.requestId,ok:true});return result;};
+try{
+ for(const control of ['missing-S5-report','missing-concept','not-S5']){
+  reset();if(control==='missing-S5-report')rows('KfArtifact').splice(2,1);else if(control==='missing-concept')delete rows('KfProject')[0].currentConceptRef;else rows('KfProject')[0].stageCode='S4';
+  const p={...copy(input),requestId:'isolated-completion-negative-'+control,...(control==='not-S5'?{toStage:'S4'}:{})};const before=copy(db);let code;
+  try{await call('ksfTransitionProject',p);}catch(e){code=e.code;}
+  check(control+' refuses completion without any mutation',()=>{assert.equal(code,control==='not-S5'?'VALIDATION_FAILED':'STAGE_REQUIREMENTS_NOT_MET');assert.deepEqual(db,before);assert.equal((db.get('KsfOperation')||[]).length,0);});
+ }
+ reset();const before=await execute(0,statusInput);
+ check('precheck has S5 in progress, three reports, one concept, seven events and thirteen questions',()=>{assert.equal(before.project.stageCode,'S5');assert.equal(before.project.progressState,'in_progress');assert.equal(before.artifacts.length,3);assert.equal(before.concept.objectId,conceptId);assert.equal(before.events.length,7);assert.deepEqual(before.project.meta.pendingQuestions,rb.pendingQuestions);});
+ const completed=await execute(1,input),final=await execute(2,statusInput);
+ check('one completion updates progress and emits exact eighth event without approval',()=>{
+  assert.equal(completed.projectId,projectId);assert.equal(completed.stageCode,'S5');assert.equal(completed.progressState,'completed');assert.equal(completed.enterpriseApproval,false);
+  assert.equal(final.project.progressState,'completed');assert.equal(final.project.status,'workshop');assert.equal(final.events.length,8);const ev=final.events[7];assert.equal(ev.objectId,completed.eventId);assert.equal(ev.eventType,'completed');assert.equal(ev.sequence,8);assert.equal(ev.fromStage,'S5');assert.equal(ev.toStage,'S5');assert.equal(ev.note,template.note);
+ });
+ check('reports, concept, formula state and thirteen questions remain unchanged',()=>{
+  assert.deepEqual(rows('KfArtifact'),initial.get('KfArtifact'));assert.deepEqual(rows('KfConcept'),initial.get('KfConcept'));assert.deepEqual(final.formulaItems,[]);assert.deepEqual(final.project.meta.pendingQuestions,rb.pendingQuestions);assert.equal(final.artifacts.length,3);assert.deepEqual(final.events.slice(0,7),initial.get('KfStageEvent'));assert.equal(rows('KfSource').length,0);
+ });
+ const snapshot=copy(db);const replay=await call('ksfTransitionProject',{...copy(input),requestId:rb.steps[1].requestId});
+ check('RAM-only replay of exact key returns same receipt without a second event',()=>{assert.deepEqual(replay,completed);assert.deepEqual(db,snapshot);assert.equal(rows('KsfOperation').length,1);});
+ let duplicateCode;try{await call('ksfTransitionProject',{...copy(input),requestId:'isolated-completion-different-key'});}catch(e){duplicateCode=e.code;}
+ check('different key after completed is refused without another write',()=>{assert.equal(duplicateCode,'VERSION_CONFLICT');assert.deepEqual(db,snapshot);assert.equal(rows('KsfOperation').length,1);});
+ check('three CLI steps, one committed operation and no new business entities',()=>{
+  assert.equal(calls.length,3);assert.deepEqual(calls.map(c=>c.requestId),rb.steps.map(s=>s.requestId));assert.equal(rows('KsfOperation').length,1);assert.equal(rows('KsfOperation')[0].requestId,rb.steps[1].requestId);
+  for(const n of ['KfOpportunity','KfFormulaItem','KfProduct','KfSku','KfMaterial','KfProcess'])assert.equal(rows(n).length,0);
+ });
+ const r={ok:true,at:new Date().toISOString(),simulatedOnly:true,productionCalls:0,productionWrites:0,checksPassed:checks.length,checks,calls,deployedCode:proof,
+  negativeControls:['S5 report required','current concept required','only S5 may complete'],idempotencyControls:['Same-key replay in RAM only','Different key after completed refused'],
+  expectedAfter:rb.expectedAfter,limits:['Synthetic identity and RAM store, not a production transaction or current-message identity test.','Exact-key replay here is an isolated test; production runbook still forbids automatic retries.']};
+ fs.writeFileSync(output,JSON.stringify(r,null,2)+'\n',{flag:'wx'});console.log(JSON.stringify({ok:true,checksPassed:checks.length,commandSteps:3,negativeControls:3,idempotencyControls:2,productionCalls:0,productionWrites:0}));
+}catch(error){const r={ok:false,step,checksPassed:checks.length,commandsCompleted:calls.length,errorCode:'S5_COMPLETION_SIMULATION_STOPPED',failureCode:error.code??null,checkLine:error.stack?.match(/simulate-S5-completion\.mjs:(\d+):/)?.[1]??null,productionCalls:0,productionWrites:0};fs.writeFileSync(output,JSON.stringify(r,null,2)+'\n',{flag:'wx'});console.error(JSON.stringify(r));process.exitCode=1;}

+ 125 - 0
scripts/split-identity-delivery.py

@@ -0,0 +1,125 @@
+"""Split the verified handoff into packages with explicit recipients; no installation."""
+from pathlib import Path, PurePosixPath
+import hashlib
+import json
+import zipfile
+
+ROOT = Path(__file__).resolve().parent.parent
+SOURCE = ROOT / 'work/delivery-aibot-identity-20261102/final/ksf-aibot-identity-20261102.zip'
+EXPECTED_SOURCE = '375ba276e88b579887c89420ec6256c90e4afba79ee1393c46e85896c66984e4'
+DEST = ROOT / 'work/delivery-by-recipient-20261102'
+sha = lambda value: hashlib.sha256(value).hexdigest()
+assert sha(SOURCE.read_bytes()) == EXPECTED_SOURCE, 'SOURCE_HASH_MISMATCH'
+
+def verify_manifest(payloads, filename='FILES.sha256', prefix=''):
+    rows = payloads[filename].decode('utf-8').strip().splitlines()
+    for row in rows:
+        fingerprint, name = row.split('  ', 1)
+        assert not PurePosixPath(name).is_absolute() and '..' not in PurePosixPath(name).parts
+        assert sha(payloads[prefix + name]) == fingerprint, 'MANIFEST_HASH_MISMATCH'
+    return len(rows)
+
+with zipfile.ZipFile(SOURCE) as z:
+    assert z.testzip() is None
+    assert len(z.namelist()) == len(set(z.namelist())) == 24
+    source = {name: z.read(name) for name in z.namelist()}
+assert verify_manifest(source) == 23
+
+def pack(name, recipient, payloads, manifest_count):
+    assert 'FILES.sha256' not in payloads
+    assert len(payloads) == manifest_count
+    for filename, data in payloads.items():
+        assert not PurePosixPath(filename).is_absolute() and '..' not in PurePosixPath(filename).parts
+        assert not filename.endswith(('.pem', '.key', '.env'))
+        assert 'credential-binding.json' not in filename
+        assert b'-----BEGIN PRIVATE KEY-----' not in data
+    payloads['FILES.sha256'] = ''.join(sha(payloads[x]) + '  ' + x + '\n' for x in sorted(payloads)).encode()
+    DEST.mkdir(parents=True, exist_ok=True)
+    target = DEST / (name + '.zip')
+    # Reproducible archive written outside the final destination first.
+    from io import BytesIO
+    stream = BytesIO()
+    with zipfile.ZipFile(stream, 'w', compression=zipfile.ZIP_DEFLATED) as z:
+        for filename in sorted(payloads):
+            z.writestr(zipfile.ZipInfo(filename, (2026, 11, 2, 0, 0, 0)), payloads[filename], compress_type=zipfile.ZIP_DEFLATED)
+    packed = stream.getvalue()
+    if target.exists():
+        assert target.read_bytes() == packed, 'DELIVERY_ALREADY_EXISTS'
+    else:
+        with target.open('xb') as f:
+            f.write(packed)
+    with zipfile.ZipFile(target) as z:
+        assert z.testzip() is None
+        unpacked = {filename: z.read(filename) for filename in z.namelist()}
+    assert unpacked == payloads
+    assert verify_manifest(unpacked) == manifest_count
+    if recipient == '王刚001':
+        assert not any(x.startswith('server/') for x in unpacked)
+        assert verify_manifest(unpacked, 'skills/ksf-workshop-runtime/FILES.sha256', 'skills/ksf-workshop-runtime/') == 9
+    else:
+        assert not any(x.startswith('skills/') for x in unpacked)
+        assert not any('install-workshop-skill' in x for x in unpacked)
+    result = dict(recipient=recipient, file=str(target), sizeBytes=len(packed), sha256=sha(packed),
+                  manifestEntries=manifest_count, zipIntegrity=True, manifestsVerified=True,
+                  derivedFromVerifiedSource=True, containsCredentials=False,
+                  installed=False, deployed=False, gatewayModified=False)
+    target.with_suffix('.json').write_text(json.dumps(result, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
+    target.with_suffix('.txt').write_text(
+        f'只发给:{recipient}\n附件:{name}.zip\n字节数:{len(packed)}\nSHA-256:{sha(packed)}\n\n'
+        '请先按包内 INSTALL.txt 核对附件与清单、审读代码,返回结果。本轮只核验和准备,不执行安装、生产发布、重启或业务写入。\n', encoding='utf-8')
+    return result
+
+skill_names = [name for name in source if name.startswith('skills/ksf-workshop-runtime/')]
+wang = {name: source[name] for name in skill_names + ['scripts/install-workshop-skill.mjs', 'checks/empty.json', 'templates/agent-self.json', 'templates/employee.json', 'templates/channel-context.example.json']}
+agent_template = json.loads(wang['templates/agent-self.json'])
+agent_template.update(name='王刚001', role='workshop')
+wang['templates/agent-self.json'] = (json.dumps(agent_template, ensure_ascii=False, indent=2) + '\n').encode()
+wang['INSTALL.txt'] = '''收件数字人:王刚001。此包仅用于升级你现有的 ksf-workshop-runtime,不给运维001安装。
+
+本轮先核验与审读,不自动执行安装或业务;下面的安装命令供后续执行时使用。
+1. 按同发执行单的字节数/SHA-256核对ZIP,在/opt/data下新建目录解压,不覆盖旧包。
+2. 包根运行sha256sum -c FILES.sha256,预期16/16 OK;技能子清单9/9 OK。
+3. 阅读scripts/install-workshop-skill.mjs、skills/ksf-workshop-runtime/SKILL.md和references/identity.md。
+4. 返回清单核验结果、已安装Skill是否匹配已知升级基线,以及当前自身botid字段是否可从可信运行配置取得;不贴完整配置、bot secret或其他凭据。
+
+后续安装命令:node scripts/install-workshop-skill.mjs。
+安装器仅升级已知基线,备份旧文件,保留原入口、runtime-lock、加密绑定和员工/Agent档案;未知内容停止,重复安装零改动。安装后重新加载Skill,并在实际安装目录用本包技能子清单检查九文件。
+自身模板固定名称为王刚001、role=workshop;accountId必须读取你实际订阅配置的botid,未知则null,不能填secret或运维001的botid。没有可信ID不声称完成自动识别。
+
+本包没有后台发布工具,也没有Hermes适配器补丁。运维001负责让当前消息的botId/from.userid等元数据通过可信适配器或hook交付,负责后台两函数的发布准备。元数据未接入前不要自己生成文件冒充消息;姓名缺失可在接通后用员工待绑定标签,不要求员工重复填写账号。
+本轮不创建员工、会话、样例或报告,不改网关、不重启,不重复已有G/H/K任务。后续真实消息验收需接入和后台能力均就绪。
+'''.encode()
+
+ops = {name: source[name] for name in source if name.startswith('server/')}
+ops['docs/wecom-aibot-identity-handoff.md'] = source['docs/wecom-aibot-identity-handoff.md'].replace(
+    b'references/identity.md', b'docs/aibot-context-contract.md')
+ops['docs/aibot-context-contract.md'] = '''# 给运维001的消息身份交付约定
+
+Skill安装在王刚001,运维001不安装或复制其Skill、加密绑定及私钥。
+当前接入为aibot WebSocket;按当前消息字段取senderId=body.from.userid、messageId=body.msgid、chatType=实际chattype、chatId=实际chatid(单聊可null)。botId来自处理该消息的机器人订阅配置,不是secret、corpId或应用agentId。
+
+交付结构:
+{"version":"ksf.wecom-aibot-context.v1","botId":"本实例实际botid","senderId":"当前消息from.userid","chatType":"single","chatId":null,"messageId":"当前消息msgid"}
+
+接入层/hook须保证元数据来自已经鉴别的当前消息,与当前执行bot和消息关联;不能接受用户正文替代,不能用全局变量或共享临时文件导致并发串人。若用文件,放/opt/data下,0600,逐消息隔离并提供失效机制,不含正文或凭据。JSON格式本身不证明来源,来源可信性须在接入层落实。
+
+王刚001的Skill按botId+senderId生成稳定员工键;无姓名显示员工待绑定,企业未知为null。群chatid不作为员工ID,跨bot未经核实不自动合并,不能从相同ID前缀认定类型。实名、工号及跨bot关联需另行受控绑定。
+
+后台只涉及ksfStartWorkshopSession与ksfGetIngestContract,新增channelIdentitySchema=ksf.wecom-aibot-sender.v1及作用域核对。账号/作用域保持私有,公开看板样式不改。本包没有现场适配器源码和可直接套用的适配器补丁。
+'''.encode()
+ops['INSTALL.txt'] = '''收件数字人:运维001。此包用于企微/Hermes接入准备与后台两函数发布准备,不安装到王刚001的Skill目录。
+
+本轮仅只读核验和准备:
+1. 按同发执行单核对ZIP字节数/SHA-256,在/opt/data新目录解压;包根sha256sum -c FILES.sha256,预期8/8 OK。
+2. 阅读docs/wecom-aibot-identity-handoff.md及docs/aibot-context-contract.md。在隔离副本准备适配器/hook补丁,返回相关版本/文件指纹、具体差异、并发与过期处理、离线测试和回退方案。此包不含现场适配器补丁,不按报告行号盲改。
+3. 审读server/scripts/deploy-identity.mjs和server/dist/identity-manifest.json。
+4. 若现有受控管理连接可用,将KSF_CONNECTION_CONFIG指向既有配置路径,运行node server/scripts/deploy-identity.mjs做只读预检。预期readOnly=true、operation=read-only-plan、两函数、schemaChanges=permissionChanges=businessWrites=0。无配置如实报告,不从聊天接收明文凭据。
+5. 返回核验结果与预检JSON/退出码。此轮不运行--apply,不改客户容器配置、不重启、不触发客户消息,也不修改静态看板。
+
+后续正式部署时才运行node server/scripts/deploy-identity.mjs --apply,涉及两个共享后台Function.code的真实写入,有备份和回退;须等接入方案、安装顺序和生产发布安排明确后执行。Skill包单独交给王刚001,由王刚001核验和安装,运维001不替它登记自身身份或复制凭据。
+'''.encode()
+
+results = [pack('ksf-wanggang001-skill-20261102', '王刚001', wang, 16),
+           pack('ksf-yunwei001-integration-20261102', '运维001', ops, 8)]
+(DEST / 'delivery-map.json').write_text(json.dumps(results, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
+print(json.dumps(results, ensure_ascii=False, indent=2))

+ 97 - 0
scripts/split-voc-database-delivery.py

@@ -0,0 +1,97 @@
+"""Produce two small ordinary ZIP attachments, then verify exact reconstruction."""
+import hashlib
+import importlib.util
+import json
+from pathlib import Path
+import shutil
+import zipfile
+
+ROOT = Path(__file__).resolve().parents[1]
+SOURCE = Path('F:/文档/康师傅skill/ksf-voc-database-20261106-r1.zip')
+OUT = ROOT / 'artifacts/研究数据库分包投递-20261106'
+WORK = ROOT / 'work/voc-database-transport-20261106'
+sha = lambda b: hashlib.sha256(b).hexdigest()
+
+def main():
+    source = SOURCE.read_bytes()
+    expected = '1ba1ac2fa6a8b774f8945d88e014c07dd5e03332792af298a41f015ba6946f9f'
+    if len(source) != 25913256 or sha(source) != expected:
+        raise ValueError('SOURCE_HASH_MISMATCH')
+    with zipfile.ZipFile(SOURCE) as z:
+        assert len(z.namelist()) == 17 and z.testzip() is None
+        manifest = z.read('FILES.sha256').decode('utf-8').splitlines()
+        assert len(manifest) == 16
+        for line in manifest:
+            h, name = line.split('  ', 1)
+            assert sha(z.read(name)) == h
+    if OUT.exists() or WORK.exists():
+        raise ValueError('TRANSPORT_OUTPUT_ALREADY_EXISTS')
+    OUT.mkdir(parents=True)
+    WORK.mkdir(parents=True)
+    chunks = [source[:14000000], source[14000000:]]
+    transport = {'schema': 'ksf.voc-database.transport.v1', 'filename': SOURCE.name,
+                 'bytes': len(source), 'sha256': sha(source), 'parts': [
+                     {'index': i, 'chunk': SOURCE.name + f'.part{i:02}', 'bytes': len(data), 'sha256': sha(data)}
+                     for i, data in enumerate(chunks, 1)]}
+    common = {'TRANSPORT.json': (json.dumps(transport, ensure_ascii=False, indent=2) + '\n').encode(),
+              'reassemble-voc-database.py': (ROOT / 'scripts/reassemble-voc-database.py').read_bytes(),
+              'README.md': (ROOT / 'docs/voc-database-delivery-20261106/发给运维001-数据库分包投递.md').read_bytes()}
+    parts = []
+    for i, data in enumerate(chunks, 1):
+        p = OUT / (SOURCE.stem + f'-part{i:02}.zip')
+        with zipfile.ZipFile(p, 'x') as z:
+            for name, value in {**common, transport['parts'][i-1]['chunk']: data}.items():
+                info = zipfile.ZipInfo(name, (2026, 11, 6, 0, 0, 0))
+                info.external_attr = 0o100600 << 16
+                z.writestr(info, value, compress_type=zipfile.ZIP_STORED if name.endswith(f'.part{i:02}') else zipfile.ZIP_DEFLATED)
+        with zipfile.ZipFile(p) as z:
+            assert z.testzip() is None and len(z.namelist()) == 4
+        assert p.stat().st_size < 20000000
+        parts.append({'filename': p.name, 'bytes': p.stat().st_size, 'sha256': sha(p.read_bytes())})
+    spec = importlib.util.spec_from_file_location('reassemble', ROOT / 'scripts/reassemble-voc-database.py')
+    reassembler = importlib.util.module_from_spec(spec)
+    spec.loader.exec_module(reassembler)
+    inputs = [OUT / p['filename'] for p in parts]
+    target = WORK / SOURCE.name
+    result = reassembler.reassemble(list(reversed(inputs)), target)
+    assert result['ok'] and result['fileCreated'] and target.read_bytes() == source
+    stamp = target.stat().st_mtime_ns
+    repeat = reassembler.reassemble(inputs, target)
+    assert repeat['operation'] == 'unchanged' and target.stat().st_mtime_ns == stamp
+    for bad, code in ((inputs[:1], 'EXACTLY_TWO_PARTS_REQUIRED'), ([inputs[0], inputs[0]], 'PART_MISSING_OR_DUPLICATED')):
+        try:
+            reassembler.reassemble(bad, WORK / SOURCE.name)
+        except ValueError as exc:
+            assert str(exc) == code
+        else:
+            raise AssertionError('BAD_INPUT_WAS_ACCEPTED')
+    # A same-size altered chunk must fail and must not leave a final or temporary file.
+    corrupt = WORK / 'corrupt-part01.zip'
+    with zipfile.ZipFile(inputs[0]) as original, zipfile.ZipFile(corrupt, 'x') as changed:
+        for name in original.namelist():
+            data = original.read(name)
+            if name.endswith('.part01'):
+                data = bytes([data[0] ^ 1]) + data[1:]
+            changed.writestr(name, data)
+    bad_dir = WORK / 'bad-output'
+    bad_dir.mkdir()
+    try:
+        reassembler.reassemble([corrupt, inputs[1]], bad_dir / SOURCE.name)
+    except ValueError as exc:
+        assert str(exc) == 'PART_HASH_MISMATCH'
+    else:
+        raise AssertionError('CORRUPTED_PART_WAS_ACCEPTED')
+    assert not list(bad_dir.iterdir())
+    receipt = {'ok': True, 'source': {'filename': SOURCE.name, 'bytes': len(source), 'sha256': sha(source)},
+               'attachments': parts, 'sourceManifestVerified': '16/16', 'transportZipEntriesEach': 4,
+               'allAttachmentsUnder20000000Bytes': True,
+               'validation': {'byteIdenticalReassembly': True, 'reversedInputs': True, 'repeatNoOpMtimePreserved': True,
+                              'missingAndDuplicatedInputsRejected': True, 'tamperedPartRejectedNoFinalFile': True},
+               'installed': False, 'productionWrites': 0, 'originalPackageModified': False}
+    (OUT / 'transport-receipt.json').write_text(json.dumps(receipt, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
+    shutil.copyfile(ROOT / 'docs/voc-database-delivery-20261106/发给运维001-数据库分包投递.md', OUT / '发给运维001-数据库分包投递.md')
+    shutil.copyfile(ROOT / 'scripts/reassemble-voc-database.py', OUT / 'reassemble-voc-database.py')
+    print(json.dumps(receipt, ensure_ascii=False, indent=2))
+
+if __name__ == '__main__':
+    main()

+ 35 - 0
scripts/test-board-deployment.mjs

@@ -0,0 +1,35 @@
+import fs from 'node:fs';
+import path from 'node:path';
+import crypto from 'node:crypto';
+import assert from 'node:assert/strict';
+import { publishFiles } from '../work/board-deploy-20261106-r1/package/scripts/publish-board.mjs';
+
+const base=path.resolve('work/board-deploy-20261106-r1');
+const pkg=path.join(base,'package'),sourceDir=path.join(pkg,'payload'),manifestPath=path.join(pkg,'deploy/board-files.json');
+const manifest=JSON.parse(fs.readFileSync(manifestPath));
+const targetDir=fs.mkdtempSync(path.join(base,'static-publish-test-'));
+for(const f of manifest.files){const p=path.join(targetDir,f.path);fs.mkdirSync(path.dirname(p),{recursive:true});fs.copyFileSync(path.join(sourceDir,f.path),p);}
+fs.copyFileSync(path.join(base,'production-index.html'),path.join(targetDir,'index.html'));
+const nav=path.join(targetDir,'src/layout/kf-nav.js');
+fs.copyFileSync(path.join(base,'production-before/src/layout/kf-nav.js'),nav);
+fs.unlinkSync(path.join(targetDir,'src/core/library.js'));
+const snapshot=()=>Object.fromEntries(['index.html',...manifest.files.map(f=>f.path)].map(p=>{const f=path.join(targetDir,p);return[p,fs.existsSync(f)?crypto.createHash('sha256').update(fs.readFileSync(f)).digest('hex'):null];}));
+const options={sourceDir,manifestPath,targetDir},before=snapshot();
+const plan=publishFiles(options);
+assert.equal(plan.readOnly,true);assert.equal(plan.filesChecked,71);assert.equal(plan.changes.length,2);assert.deepEqual(snapshot(),before);
+let thrown;
+try{publishFiles({...options,apply:true},{beforeWrite:p=>{if(p==='src/layout/kf-nav.js')throw Error('simulated filesystem failure');}});}catch(e){thrown=e;}
+assert.equal(thrown?.rollbackComplete,true);assert.deepEqual(snapshot(),before);
+fs.writeFileSync(nav,'unknown concurrent revision');
+assert.throws(()=>publishFiles({...options,apply:true}),{code:'UNKNOWN_TARGET_CONTENT'});
+assert.equal(fs.readFileSync(nav,'utf8'),'unknown concurrent revision');
+fs.copyFileSync(path.join(base,'production-before/src/layout/kf-nav.js'),nav);
+const applied=publishFiles({...options,apply:true});
+assert.equal(applied.filesystemVerified,true);assert.equal(applied.homepageModified,false);assert.ok(!applied.backup.startsWith(targetDir+path.sep));
+const stamp=fs.statSync(nav).mtimeMs,after=snapshot();
+assert.equal(publishFiles({...options,apply:true}).changes.length,0);assert.equal(fs.statSync(nav).mtimeMs,stamp);assert.deepEqual(snapshot(),after);
+fs.writeFileSync(path.join(targetDir,'index.html'),'changed homepage');
+assert.throws(()=>publishFiles(options),{code:'HOMEPAGE_MISMATCH'});
+const result={ok:true,checks:['dry run writes nothing','failure restores changed and newly created files','unknown target revision is preserved','apply verifies all 71 files and preserves homepage','repeat is a no-op preserving mtime','changed homepage is refused'],productionWrites:0};
+fs.writeFileSync(path.join(base,'static-publisher-validation.json'),JSON.stringify(result,null,2));
+console.log(JSON.stringify(result));

+ 195 - 0
scripts/update-S4-acceptance.py

@@ -0,0 +1,195 @@
+"""Write acceptance and current progress locally. No business API calls."""
+import json
+from pathlib import Path
+from datetime import datetime, timezone
+
+root = Path('F:/kangshifu-board')
+docs = Path('F:/文档/工作坊当前资料')
+readback = json.loads((root / 'work/live-S4-readback-20261103.json').read_text(encoding='utf-8'))
+browser = json.loads((root / 'work/live-S4-browser-verified-20261103.json').read_text(encoding='utf-8'))
+display = json.loads((root / 'work/live-S4-display-inspection-20261103.json').read_text(encoding='utf-8'))
+assert readback['ok'] and browser['ok'] and display['ok']
+assert readback['artifactId'] == browser['artifactId'] == 'U3_W7WDliP'
+assert all(c['passed'] for c in browser['checks'])
+assert display['display']['metricLabels'][0] == '—人独立提到'
+
+out = root / 'work/live-S4-independent-acceptance-20261103.json'
+report_file = docs / '50-S4课堂桌面评估独立验收结果.md'
+assert not out.exists() and not report_file.exists()
+backup = root / 'work/S4-progress-before-acceptance-20261103'
+backup.mkdir()
+for name in ['01-工作进展汇报.txt', '02-后续待补资料.md', 'README.md']:
+    with (backup / name).open('xb') as f:
+        f.write((docs / name).read_bytes())
+
+issues = [
+    {'severity': 'display-polish', 'area': 'retail listing evidence drawer',
+     'observed': 'Unknown owner count displays as —人独立提到, not a numeric count. Generic person label is inappropriate for a retail listing.',
+     'recommended': 'Hide the person metric when unknown, or use a source-based metric with the correct denominator.'},
+    {'severity': 'display-polish', 'area': '390px provenance text',
+     'observed': 'Drawer body clientWidth=389, scrollWidth=410. Full provenance is reachable with 21px horizontal scrolling; page width remains 390px.',
+     'recommended': 'Wrap long provenance URLs within the drawer.'},
+]
+receipt = {
+    'ok': True, 'at': datetime.now(timezone.utc).isoformat(),
+    'operation': 'S4-independent-acceptance', 'readOnly': True, 'businessWrites': 0,
+    'sessionId': readback['sessionId'], 'projectId': readback['projectId'],
+    'artifactId': readback['artifactId'], 'sourceId': readback['outputSourceId'],
+    'reportUrl': readback['reportUrl'], 'stageCode': 'S4', 'progressState': 'in_progress',
+    'dataChecksPassed': readback['checksPassed'], 'browserChecksPassed': len(browser['checks']),
+    'focusedDisplayChecksPassed': len(display['checks']),
+    'browserVerified': True, 'productionBrowserVerifiedFieldWritten': False,
+    'formalReportPublished': False, 'employeeConfirmed': False, 'enterpriseApproval': False,
+    'physicalStudyPerformed': False, 'marketValidationPerformed': False, 'productionValidationPerformed': False,
+    'projectArtifactIds': readback['projectArtifactIds'], 'projectEvents': readback['projectEvents'],
+    'knownDisplayIssues': issues,
+    'visualReview': [s for s in browser['screenshots'] if any(k in s for k in [
+        'table-1-desktop', 'table-1-mobile', 'table-2-mobile', 'evidence-public-p1-mobile'])],
+    'evidenceFiles': ['work/live-S4-readback-20261103.json', 'work/live-S4-browser-verified-20261103.json',
+                      'work/live-S4-display-inspection-20261103.json'],
+    'remoteLogsAndPermissionsBasis': 'Operator report only; not independently read by Codex',
+}
+with out.open('x', encoding='utf-8', newline='\n') as f:
+    json.dump(receipt, f, ensure_ascii=False, indent=2)
+    f.write('\n')
+
+report = f'''# S4课堂桌面评估独立验收结果
+
+2026年11月3日。Codex已完成生产数据只读核对与匿名生产浏览器验收。S4课堂桌面评估保存、展示及项目关联通过;保留两项显示改进,不据此认定实物、市场或量产验证完成。
+
+| 项目 | 独立核实结果 |
+|---|---|
+| 选定方向 | 燃魂份量与用餐目的适配(沿用用户既有选择) |
+| 会话 | btrFqkz0yw |
+| 机会卡 | QE9hjtCj8E,claimKind=hypothesis;建课堂项目后status=approved,不代表需求已证实或企业立项 |
+| 项目 | S_CikxZeBs,S4 / in_progress,dataMode=live,status=workshop |
+| S4成果 | U3_W7WDliP,version=1,正文来源y-KcU9Ijc0 |
+| 类型 | artifactType=assessment_report;reportType=workshop_auxiliary;artifactSubtype=classroom_task |
+| 系列 | KSF-真实资料-20261102-01:S4,由明确输入taskKey写入reportSeriesKey |
+| 数据核对 | {readback['checksPassed']}项通过;正文1862码点、4来源/4主引文、8待补、4章节与固定05模板相同 |
+| 生产浏览器 | 1440px电脑及390px手机,{len(browser['checks'])}项功能检查通过;另4项定点显示检查通过 |
+| 项目成果 | 恰2份:XF4h-Z7l5o(项目S3基线)、U3_W7WDliP(S4桌面评估) |
+| 写入审计 | 此课堂链5条成功写入,各原业务键恰1条;没有第二套机会卡、项目或S4版本 |
+| 仍未发生 | S5方案保存、实物试验、市场验证、量产验证、员工确认、正式发布、企业批准 |
+
+报告:[燃魂份量适配|公开资料桌面评估 第1版](https://kangshifu.fmode.cn/report/U3_W7WDliP)。
+
+项目事件实测顺序如下,编号保留下划线:
+
+| 序号 | eventId | eventType | 关联成果 |
+|---|---|---|---|
+| 1 | GGDTcg5YTy | project_created | — |
+| 2 | RkkTTE_xeb | task_submitted | XF4h-Z7l5o |
+| 3 | hOFghbcMsY | stage_changed | — |
+| 4 | VlGFu_jv4b | task_submitted | U3_W7WDliP |
+
+S4 sourceIds精确为m1OQx9aynA / QdeMcyTTjX / PjORcvaRHn / 5S_Y4IgT5q。对应证据_9mUml0wmh / 2bFGP7axMo / xHGV_mRWcG / CeAEIwymTa逐条回读,quote逐字存在来源rawText,sourceRef指回同源,productRef=null。实际枚举保留下划线。P1是公开图片页观察,P2是历史个人自述;未拼成同SKU、未认定当前在售或普遍需求。
+
+模板的report.chapters/evidence/method/sources/subtitle逐项相同;executive仅前加课堂声明,report.title/generatedAt属预期新增。tags含拌面及课堂过程成果。S4与基线projectRef均指向本项目,会话与Agent归属一致。旧S1第2版、S2第1版及S3两版的正文与结构仍符合各原模板;同workshop现有8份成果,没有S5成果。
+
+页面核实了三张表:主引文3列×4行、桌面评估4列×5行、待补问题2列×8行。两种屏宽逐表对照全部列和行,检查逐行滚动、各列可达、四个引文抽屉的真实鼠标展开与Escape关闭、逐字原话及来源关联。引文保留原换行,仅忽略显示层插入的U+2009细空格。项目成员页显示S4、2份成果、进行中、方案待整理;两份新S3报告及旧报告仍可打开。浏览器没有运行或网络错误,仅匿名调用公开看板读取,无服务凭据和业务写入。截图已目视复核。
+
+两处显示改进单列,未在本轮改前端或发布:
+
+1. 商品页证据抽屉沿用“—人独立提到”标签。横线是人数未知,实际没有“1人”的计数;Codex最初目视误认已通过定点DOM检查更正。建议人数未知时隐藏该指标,或按实际来源口径展示,不能把商品页换算为消费者人数。
+2. 手机抽屉长出处需要横向滚动:body可用宽389px、内容宽410px,可横移21px,出处完整且页面本身宽390px。建议长URL自动换行。表格没有页面级横向溢出;较窄列的阅读仍需纵向滚动。
+
+第08旧USAGE失败与成功恢复分别留存,不说首次调用即全成功。旧日志、55文件权限、CLI退出码、stderr及两层调用编号依据王刚001回报,Codex没有独立读取远端文件系统。独立数据证实原转阶段业务键现有且仅有1条成功审计及1条对应事件;这不抹去先前本地失败,也不扩大证明历史无任何HTTP尝试。
+
+boardVisible=true、formalReportPublished=false、employeeConfirmed=false已从保存成功审计核实;dashboardState=workshop与dashboardPublishedAt=null分别表示课堂状态及未正式发布。browserVerified=true记录在本地Codex验收证据,未回写生产字段。enterpriseApproval=false,机会卡approved不代表企业批准。
+
+下一步可整理S5桌面方案草稿并审阅,再准备对应系统执行材料;本轮没有推进S5。当前SKU、实物、需求强度、配方、成本与量产依据继续保留未知。继续软件课堂流程测试不要求用户先补实物;实际试吃或工艺研究再明确所需样品和条件。不要重导资料、重建卡/项目、重存S3或S4,也不覆盖第08旧失败回执。
+
+本地证据(不含凭据与浏览器档案):
+
+- F:/kangshifu-board/work/live-S4-independent-acceptance-20261103.json
+- F:/kangshifu-board/work/live-S4-readback-20261103.json
+- F:/kangshifu-board/work/live-S4-browser-verified-20261103.json(18张截图索引)
+- F:/kangshifu-board/work/live-S4-display-inspection-20261103.json(定点DOM及滚动尺寸)
+
+01、02及README更新前的完整快照保留在F:/kangshifu-board/work/S4-progress-before-acceptance-20261103/;既有交接单、包、报告、原件及业务回执未改动。
+'''
+with report_file.open('x', encoding='utf-8', newline='\n') as f:
+    f.write(report)
+
+progress = '''工作坊进展(截至2026年11月3日,最新独立验收见50号)
+
+真实资料课堂流程已推进到S4 / in_progress,选定方向为“燃魂份量与用餐目的适配”。S4公开资料桌面评估已保存,Codex独立核对生产数据与电脑/手机页面通过。尚未保存S5方案,未做实物、市场或量产验证。
+
+当前会话btrFqkz0yw;独立课堂机会卡QE9hjtCj8E;项目S_CikxZeBs。项目恰2份成果:S3基线XF4h-Z7l5o、S4评估U3_W7WDliP;4条有序事件。此链5条成功写入审计,各业务键恰1条,没有重复卡、项目或S4版本。
+
+Codex本轮15项数据检查、177项生产浏览器功能检查通过;4来源/4引文、8待补问题、4章节和3张表与固定模板一致。两项显示改进已单列:商品页抽屉仍用人数未知标签,手机长出处需抽屉内横滚。原文与来源完整、流程可操作,本轮未改前端或发布。
+
+S1资料小结第2版、S2用户需求小结第1版、S3三项改进假设第2版的保存与独立验收均已完成,旧记录保留。历史帖子只证明个别作者这样说,公开商品页只证明当时页面/图示展示;不证明当前在售、普遍需求或已取得实物。S4报告是公开资料一致性与证据缺口评估。
+
+第08曾用无效命令project-transition本地USAGE失败,后按合法project-progress、原输入和原业务键恢复成功,09—12完成。失败回执仍保留,不记为首次即成功;远端日志数量、权限和CLI退出情况依据王刚001回报,独立成功状态以50号数据核查为准。
+
+课堂可见不等于正式发布、员工确认或企业批准。browserVerified=true仅为本地Codex验收记录,没有回写生产。实物、当前SKU、需求强度、支付意愿、配方、成本及量产依据仍未知。
+
+下一步整理并审阅S5桌面方案草稿,再准备系统执行材料;无需重做S1—S4、重新选方向或重复找实物。软件课堂测试不要求用户先提供照片;实际试吃/工艺研究所需样品另行安排。新数字人接入、员工实名绑定及旧机器人密钥轮换仍是待办。
+
+看板:https://kangshifu.fmode.cn/
+最新S4桌面评估:https://kangshifu.fmode.cn/report/U3_W7WDliP
+S3假设第2版:https://kangshifu.fmode.cn/report/ZW1FB5zeHr
+S2小结第1版:https://kangshifu.fmode.cn/report/b7NOby5ys0
+S1小结第2版:https://kangshifu.fmode.cn/report/G8IrkkDG7X
+
+详细验收:50-S4课堂桌面评估独立验收结果.md。
+更新前汇报快照:F:/kangshifu-board/work/S4-progress-before-acceptance-20261103/。
+'''
+(docs / '01-工作进展汇报.txt').write_text(progress, encoding='utf-8', newline='\n')
+
+pending = '''# 后续待补资料
+
+2026年11月3日更新。课堂系统当前S4 / in_progress;S4报告U3_W7WDliP的数据与电脑/手机功能已独立验收,详见50号。下一步可写S5桌面方案草稿;下面研究缺口不作为全部软件流程测试的统一前置,不要求用户现在提供原件,也不重查已知登录墙。
+
+| 研究事项 | 已有依据 | 仍缺什么及何时需要 |
+|---|---|---|
+| 明确研究对象 | 苏宁公开图片页展示燃魂浓浓炸酱面117g×5、参数585克,关联图标含料117克、面饼90克、煮6—7分钟 | 当前具体实物版本、批次、渠道/日期、可取得性;实际实物研究前明确。图示不当称量值或在售认定 |
+| SKU对应 | cq-03/cq-04旧帖、P1商品图片页与P2历史包装照片分别归档 | 精确对应未核;不能拼成同SKU、同批次,productRef均未绑定 |
+| 份量与用途 | 两篇2023年份量自述,保留正负上下文;未进行本轮试吃 | 用餐目的、习惯、提前或吃后补主食的原因及实际行为;开展人员研究时再定对象与方法 |
+| 人群与市场 | 同一平台4篇历史体验及另1篇2023年历史帖子 | 当前人群/场景、需求强度、支付意愿与普遍程度;不根据帖子算比例或KANO档位 |
+| 规格版本与价格 | 单页面算术核对;历史/海外资料各有局限 | 燃魂常规/加量具体版本、当前可取得情况,以及日期/地区/渠道/组合/优惠一致的可比价格 |
+| 研究设计与执行 | S4保留研究条件及继续/调整逻辑 | 目标、负责人、资源、对象、样本、记录/比较方法及预先判断规则;真正执行时明确,本轮不虚填数值 |
+| 研发与落地 | 未取得企业资料 | 配方、原料/供应商、工艺、产线、成本及相应合规依据;用于实际评定时再补,不宣称低成本或可量产 |
+| 其他两假设 | 旧购前未注意需煮和香辣留水经验,分别保留 | 购买路径理解需对应页面/人员观察;香辣留水另锁具体商品与量化操作,不复用燃魂条件 |
+
+公开资料入库已完成,无需重导:P1来源PjORcvaRHn/证据xHGV_mRWcG;P2来源5S_Y4IgT5q/证据CeAEIwymTa。四旧消费者来源和证据沿用原编号。来源原件及哈希、库内核对见42、45、50号。
+
+S4这次没有产生试吃、称量、问卷、A/B或市场结果。实物原件、称量和获取凭证只在实际实物研究需要时补;41号为可选补充,40号状态C只限定当时可访问本机材料范围,不代表公司没有商品。
+
+软件流程下一步:审阅S5桌面方案草稿,将规格、配方、成本等未知明确列空或待核,随后按合同准备方案和报告的执行材料。尚未创建concept、尚未转S5,不因进度标签声称研究完成或正式立项。
+
+前端两项独立待改:商品页证据抽屉的人数标签、390px长出处自动换行。功能验收通过,本轮未改前端。
+
+员工实名绑定、新数字人创建/接入及旧机器人密钥轮换仍待处理。更新前资料清单完整快照位于F:/kangshifu-board/work/S4-progress-before-acceptance-20261103/。
+'''
+(docs / '02-后续待补资料.md').write_text(pending, encoding='utf-8', newline='\n')
+
+readme_path = docs / 'README.md'
+index = (backup / 'README.md').read_text(encoding='utf-8')
+# Preserve historical commentary in the snapshot; remove obsolete appended current-state blocks.
+index = index[:index.index('当前状态:')]
+replacements = {
+    '- 47-': '- 47-发给王刚001-S4课堂项目与桌面评估.txt:已完成01—07及经49号恢复的08—12。S4已保存并独立验收,见50号;保留原单,不重跑。',
+    '- 48-': '- 48-S4课堂流程预检裁定与执行补充.txt:三处合同差异及隔离模拟的历史裁定;实际生产已执行并验收,见50号。',
+    '- 49-': '- 49-发给王刚001-S4第08步续跑.txt:已按project-progress原业务键恢复成功并完成09—12,旧失败保留;不再次执行。',
+}
+lines = []
+for line in index.splitlines():
+    replacement = next((value for key, value in replacements.items() if line.startswith(key)), line)
+    lines.append(replacement)
+index = '\n'.join(lines) + '\n'
+index = index.replace('- ksf-S4-classroom-next-20261103.zip:', '- 50-S4课堂桌面评估独立验收结果.md:当前结论;15项数据、177项生产浏览器功能及4项定点检查通过,保留两项显示改进;系统S4/in_progress,未实物/市场/量产验证。\n- ksf-S4-classroom-next-20261103.zip:', 1)
+index = index.replace('当前入口:', '当前入口:\n\n- 最新S4桌面评估:https://kangshifu.fmode.cn/report/U3_W7WDliP\n- 项目S3基线:https://kangshifu.fmode.cn/report/XF4h-Z7l5o')
+current = '''当前状态(2026年11月3日,覆盖旧交接单内的当时状态):S4 / in_progress。S4报告U3_W7WDliP、项目S_CikxZeBs已独立验收;项目2份成果、4条事件,未创建S5方案。详细结论见50号;下一步审阅S5桌面方案草稿,不重跑已成功步骤,也不要求用户先交实物。两项显示改进已留档,未正式发布或员工确认。
+
+以下编号材料保留交接历史,不代表仍待执行。当前进展看01号,后续研究资料看02号;旧01/02/README完整快照在F:/kangshifu-board/work/S4-progress-before-acceptance-20261103/。
+
+'''
+index = index.replace('整理日期:2026年11月3日。', current + '整理日期:2026年11月3日。', 1)
+readme_path.write_text(index, encoding='utf-8', newline='\n')
+print(json.dumps({'ok': True, 'report': str(report_file), 'receipt': str(out), 'currentStage': 'S4',
+                  'progressState': 'in_progress', 'dataChecks': readback['checksPassed'],
+                  'browserChecks': len(browser['checks']), 'knownDisplayIssues': len(issues),
+                  'backup': str(backup), 'businessWrites': 0}, ensure_ascii=False))

+ 44 - 0
scripts/update-S4-public-progress.py

@@ -0,0 +1,44 @@
+"""Update only current progress notes; preserve all earlier handoff records."""
+from pathlib import Path
+
+docs = Path('F:/文档/工作坊当前资料')
+progress = docs / '01-工作进展汇报.txt'
+text = progress.read_text(encoding='utf-8')
+start = text.index('最新一步:')
+text = text[:start] + '''最新一步:S3第2版ZW1FB5zeHr及135项生产页面检查均已独立验收通过,完整结论见37号;课堂可见,未正式发布。S4准备稿已在39号修订。40号状态C仅说明此前数字人可访问资料中没有实物原件,不代表网上找不到测试资料。
+
+用户明确可网上采集后,Codex于11月3日实际取得苏宁公开图片页12447641634及一篇2023年什么值得买帖子,两页HTTP200且匿名浏览器可读。苏宁页面标题为燃魂浓浓炸酱面117g×5,参数585克;关联单袋图可读含料净含量117克、面饼90克、煮6—7分钟。历史帖子另有包装背面制备步骤照片。以上均是页面或图示标示,不是当前实物、库存或称量验证;两来源不合并SKU。
+
+本轮不需要用户先提供照片。42号已整理核查结果,43号已形成S4公开资料桌面评估草稿;原件包ksf-S4-public-materials-20261103.zip含31文件,清单30/30核验通过。41号外部补料改为日后实物研究的可选材料,不作为全部流程测试的统一前置。下一步可围绕公开资料继续测试规格/说明核对与证据缺口评估;进入系统S4前仍须按合同确认所选方向并关联课堂机会卡及项目。实际试吃、量产与市场结论未做,系统保持S3;本轮业务接口0次,旧报告及回执保留。
+'''
+progress.write_text(text, encoding='utf-8')
+
+pending = docs / '02-后续待补资料.md'
+text = pending.read_text(encoding='utf-8')
+start = text.index('燃魂商品的新增核查见29号:')
+text = text[:start] + '''燃魂先前核查见29号:海外页面标题与图片部分存在重量冲突,历史价格表和宽品类公告不能锁定国内当前实物。38号验证准备已在39号修订,不需重复写稿。40号状态C限定为王刚001当时可访问机器的材料范围,没有证明公司没有商品;远端文件检索依据其回报。
+
+11月3日新增网上资料见42号:Codex实际取得苏宁12447641634公开图片页和2023年帖子aeqxm74k,两页HTTP200且匿名浏览器可读,保留正文、截图及7张选定图片原文件。苏宁标题117g×5与参数585克算术吻合,单袋图标含料净含量117克、面饼90克、煮6—7分钟。历史帖子有浓浓炸酱面包装背面的分步说明。这是本轮网页展示观察,图片版本、当前库存和实物取得未知,不将两来源拼成同SKU,不映射为旧帖已绑定产品。
+
+当前不需用户先提供包装照片。41号改为可选实物补料说明,不再是流程测试必经前置。43号S4公开资料桌面评估草稿已经完成,区分规格展示一致性、煮制告知信息存在性、份量假设及企业资料缺口。可据此继续真实公开资料支持的流程测试,不重复本机样品检索,也不重跑已知登录墙。
+
+如果接下来进入系统S4,应按合同先确认选定方向,建立或关联独立课堂机会卡和项目,再保存项目下评估;当前未执行这些写入。实际称量、试吃、用户认知测试和工艺/成本评估另需对应实物、人员或企业资料,不冒充已完成。系统仍S3,S1/S2/S3与原回执不变。本轮业务接口0次,未采购或联系他人。
+'''
+pending.write_text(text, encoding='utf-8')
+
+readme = docs / 'README.md'
+text = readme.read_text(encoding='utf-8')
+old39 = next(line for line in text.splitlines() if line.startswith('- 39-'))
+text = text.replace(old39, '- 39-发给王刚001-S4验证准备审阅与修订定稿.txt:保留研究方法修订;当前网上流程测试材料已补齐,最新结果见42号和43号,不把实物取得设为全部测试统一前置。')
+old41 = next(line for line in text.splitlines() if line.startswith('- 41-'))
+text = text.replace(old41, '''- 41-研究样品外部补料说明.txt:保留旧说明,现为可选。实际实物研究需要时再补,不要求用户先提供,也不是当前网上流程测试前置;未代发。
+- 42-网上测试资料核查与推进说明.md:Codex已取得苏宁燃魂浓浓炸酱面117g×5公开图片页和历史包装背面图,实际浏览器与原图核对结果,含证据范围及推进方式。
+- 43-S4公开资料桌面评估草稿.md:已形成可审阅正文,规格展示核对、煮制告知、份量假设与缺口分开;尚未保存系统S4,不需重复起草准备计划。
+- ksf-S4-public-materials-20261103.zip:本轮新采集原件与说明,6100444字节,SHA256=88776415ab6e80e9a5b1220bf5836f5022d4c0a7814cc251128e052b0cb9009f;31文件、清单30/30核验通过,不含可执行业务输入或浏览器档案。''')
+start = text.index('当前状态:')
+end = text.index('\n\n商品页面观察:', start)
+text = text[:start] + '''当前状态:真实S1第2版、S2第1版和S3第2版均已保存并通过后台与生产页面独立验收,沿用会话btrFqkz0yw。S3新报告ZW1FB5zeHr、正文来源G1Gzu0_sQV;135项生产浏览器检查通过。Codex验收记录browserVerified=true未回写数据库,原回执保留。
+
+用户要求继续网上找测试内容后,Codex实际取得苏宁公开图片页12447641634及2023年帖子aeqxm74k。苏宁页面所示浓浓炸酱面117g×5、参数585克,单袋图可读面饼90克和煮6—7分钟;历史背面图有步骤。均明确为网页/图示证据,不是当前实物验证。42号结果与43号桌面评估草稿及原件包已就绪,无需用户先提供照片,41号改为可选实物补料。下一步可继续来源关联和评估流程测试,进入系统S4前按合同明确选择并关联独立课堂机会卡/项目。本轮没有任何workshop接口调用或业务写入;系统仍S3,实际试吃、市场验证、量产结论、员工确认和正式发布未做,旧报告与回执不动。''' + text[end:]
+readme.write_text(text, encoding='utf-8')
+print('Updated only 01, 02, README. Earlier handoff documents and business records preserved.')

+ 295 - 0
scripts/update-S5-acceptance.py

@@ -0,0 +1,295 @@
+"""Record the independent S5 acceptance locally; never call business APIs."""
+from datetime import datetime, timezone
+from hashlib import sha256
+import json
+from pathlib import Path
+
+ROOT = Path('F:/kangshifu-board')
+DOCS = Path('F:/文档/工作坊当前资料')
+WORK = ROOT / 'work'
+BACKUP = WORK / 'S5-progress-before-acceptance-20261103'
+RECEIPT = WORK / 'live-S5-independent-acceptance-20261103.json'
+REPORT = DOCS / '55-S5方案与课堂报告独立验收结果.md'
+
+def digest(path):
+    return sha256(path.read_bytes()).hexdigest()
+
+data_path = WORK / 'live-S5-readback-20261103-02.json'
+browser_path = WORK / 'live-S5-browser-verified-20261103.json'
+data = json.loads(data_path.read_text(encoding='utf-8'))
+browser = json.loads(browser_path.read_text(encoding='utf-8'))
+assert data['ok'] and data['checksPassed'] == 14
+assert browser['ok'] and browser['browserVerified']
+assert len(browser['checks']) == 235 and all(c['passed'] for c in browser['checks'])
+assert not browser['errors']
+assert data['executionConformsToFixedRequestIds'] is False
+assert data['requestIdDeviation']['expectedKeyCommitted'] is False
+assert data['artifactId'] == browser['artifactId'] == 'tLkz2MAoIw'
+assert data['conceptId'] == browser['conceptId'] == 'QO0uo8nisk'
+
+immutable = {
+    ROOT / 'work/S5-classroom-next-20261103/01-project-S5-progress.template.json': '049251b5c1d1984cae72c216ac025e9eb68bd87d02de16166cc7dd4f19bce213',
+    ROOT / 'work/S5-classroom-next-20261103/02-concept-save.template.json': '5c182b5c5733218577bca330df90b85b91d69ad1b6b1e5494bab5b06e2238e4d',
+    ROOT / 'work/S5-classroom-next-20261103/03-project-S5-save.template.json': 'f8dc7d8a9f0b2a998f90453add9ca02f34a60cfc52be3bc592deab87d49ada8c',
+    DOCS / 'ksf-S5-classroom-next-20261103.zip': '70039fcbecd3bd2bef9f36659c8a55c270f8472e57ea7b6488c8a9f8ff5f5ff1',
+}
+for path, expected in immutable.items():
+    assert digest(path) == expected, path
+for name in ['52-S5桌面方案审阅与定稿.md', '53-发给运维001-S5方案与报告准备.txt', '54-发给王刚001-S5方案与课堂报告保存.txt']:
+    path = DOCS / name
+    immutable[path] = digest(path)
+
+assert not BACKUP.exists() and not RECEIPT.exists() and not REPORT.exists()
+originals = {name: (DOCS / name).read_bytes() for name in ['01-工作进展汇报.txt', '02-后续待补资料.md', 'README.md']}
+
+visual_files = [
+    'work/live-S5-project-desktop-1793683536092.png',
+    'work/live-S5-table-0-desktop-1793683536092.png',
+    'work/live-S5-project-mobile-1793683536092.png',
+    'work/live-S5-table-0-mobile-1793683536092.png',
+    'work/live-S5-table-4-mobile-1793683536092.png',
+    'work/live-S5-evidence-public-p1-mobile-1793683536092.png',
+]
+for path in browser['screenshots']:
+    assert (ROOT / path).is_file()
+
+receipt = {
+    'at': datetime.now(timezone.utc).isoformat(),
+    'operation': 'live-S5-independent-acceptance',
+    'contentAndPageAccepted': True,
+    'executionConformsToFixedRequestIds': False,
+    'requestIdDeviation': data['requestIdDeviation'],
+    'readOnly': True,
+    'businessWrites': 0,
+    'sessionId': data['sessionId'],
+    'opportunityId': data['opportunityId'],
+    'projectId': data['projectId'],
+    'conceptId': data['conceptId'],
+    'artifactId': data['artifactId'],
+    'outputSourceId': data['outputSourceId'],
+    'reportUrl': data['reportUrl'],
+    'stageCode': 'S5',
+    'progressState': 'in_progress',
+    'checks': {'data': 14, 'productionBrowser': 235},
+    'browserVerified': True,
+    'browserVerificationStoredLocallyOnly': True,
+    'viewports': browser['viewports'],
+    'visualReview': {'performed': True, 'screenshots': visual_files},
+    'reportTables': data['reportTables'],
+    'rawTextCodepoints': 3655,
+    'chapterCount': 5,
+    'sectionCount': 15,
+    'tableCount': 5,
+    'sourceIds': data['sourceIds'],
+    'evidenceIds': data['evidenceIds'],
+    'pendingQuestionCount': 13,
+    'projectArtifactIds': data['projectArtifactIds'],
+    'projectEvents': data['projectEvents'],
+    'committedWriteCount': 3,
+    'committedWriteRequests': data['committedWriteRequests'],
+    'runbookReadCommandCount': 5,
+    'countsBasis': 'Three committed write commands verified from operation audit; five read commands specified by the eight-step runbook. Remote CLI execution counts, exit codes and invocation IDs are operator-reported.',
+    'oldReportsPreserved': True,
+    'formalReportPublished': False,
+    'employeeConfirmed': False,
+    'enterpriseApproval': False,
+    'researchValidationPerformed': False,
+    'fieldLocations': data['fieldLocations'],
+    'retainedDisplayObservations': [
+        '390px four-column tables need substantial vertical scrolling; all cells remain reachable.',
+        'Retailer listing drawer retains the generic unknown-person-count label; the dash means unknown, not one consumer.',
+        'Long source locator in the mobile drawer needs inner horizontal scrolling; no page-level overflow.',
+    ],
+    'remoteFilesystemIndependentlyVerified': False,
+    'initialStrictAuditRetained': str(WORK / 'live-S5-readback-20261103.json'),
+    'auditInspectionRetained': str(WORK / 'S5-write-audit-inspection-20261103.json'),
+    'evidenceFiles': [
+        {'path': str(p), 'sha256': digest(p)}
+        for p in [data_path, browser_path, WORK / 'S5-write-audit-inspection-20261103.json', WORK / 'live-S5-readback-20261103.json']
+    ],
+    'screenshots': [{'path': str(ROOT / p), 'sha256': digest(ROOT / p)} for p in browser['screenshots']],
+    'immutableFiles': [{'path': str(p), 'sha256': h} for p, h in immutable.items()],
+    'documentationBackup': str(BACKUP),
+}
+
+report = '''# S5方案与课堂报告独立验收结果
+
+2026年11月3日。Codex独立核对生产数据及匿名电脑、手机页面后,确认S5概念、报告内容与项目关联通过验收。执行过程存在一处报告保存requestId偏差,必须保留,不能表述为“全部按54号原业务键完成”。本轮仅只读验收及本地文件整理,业务写入0次。
+
+| 项目 | 独立核实结果 |
+|---|---|
+| 方向 | 燃魂份量与用餐目的适配,沿用用户选择 |
+| 会话 / 机会卡 | btrFqkz0yw / QE9hjtCj8E;机会仍为假设,课堂approved不代表企业批准 |
+| 项目 | S_CikxZeBs,S5 / in_progress,dataMode=live |
+| 概念 | QO0uo8nisk,version=1,燃魂份量选择提示与搭配指引(待验证概念) |
+| S5报告 / 正文来源 | tLkz2MAoIw / 5zsPtYFL1u;version=1 |
+| 报告类型 | artifactType=concept;reportType=workshop_auxiliary;artifactSubtype=classroom_task |
+| 系列 / 标签 | KSF-真实资料-20261102-01:S5;拌面、课堂过程成果 |
+| 数据检查 | 14项通过,正文3655码点、概念内容、4来源/4引文、13待补问题与固定模板一致 |
+| 生产页面 | 1440px电脑、390px手机,235项功能检查通过;22张截图,6张代表截图目视复核 |
+| 项目成果 | 恰3份:XF4h-Z7l5o(S3)、U3_W7WDliP(S4)、tLkz2MAoIw(S5) |
+| 当前方案 | currentConceptRef→KfConcept/QO0uo8nisk;同项目概念恰1个,无配方条目 |
+| 仍未发生 | 课堂完成、员工确认、正式发布、企业批准、实物/市场/量产验证 |
+
+报告:[燃魂份量选择提示与搭配指引|S5桌面方案 第1版](https://kangshifu.fmode.cn/report/tLkz2MAoIw)。
+
+## 执行键偏差及处置
+
+| 第06步报告保存 | 完整requestId |
+|---|---|
+| 54号规定 | KSF-真实资料-20261102-01:S5:save:v1 |
+| 生产实际成功 | KSF-真实资料-20261102-01:portion-fit-project:S5:save:v1 |
+
+独立读取KsfOperation成功审计及对应回执,确认实际键关联报告tLkz2MAoIw和正文5zsPtYFL1u;规定键没有成功提交审计。其余两个写入键与执行单一致。reportSeriesKey仍正确为KSF-真实资料-20261102-01:S5,不能把requestId与报告系列键混为一谈。
+
+首次严格验收在此项停止,原失败记录保留;随后按已返回业务编号定位实际审计,再进行内容和页面只读验收。执行键为什么变化尚未查明,Codex未取得远端命令和原始日志,不作归因。以后交接沿用实际成功键与业务编号;**不得用尚未使用的规定键补跑保存,不重存、不换键、不删记录,不回改54号或原包来掩盖偏差。** 只需下次交接附上实际第06步命令与回执用于溯源,不影响现有报告使用。
+
+S5本轮实测恰3条成功写入审计:workshop-transition(转S5)、workshop-concept(保存概念)、workshop-task(保存报告)。对应3个写命令,事件属于这些命令的副作用;不能另计为第四个写命令。8步执行单由3个写命令和5个读命令组成。读命令的实际次数、退出码、stderr、两层invocationId和远端29个文件权限仍依据王刚001回报,Codex没有独立读取远端文件系统,也不用写审计证明只读命令从未运行。
+
+## 内容及字段核对
+
+概念九项内容字段及specification九个子字段与模板相同。netWeightG/noodleWeightG/packCount/currentSku等未知项保留null,formulaItems与productionSteps为空;没有把P1图示克重当新品规格,没有补造配方、成本或工艺。KfConcept.reviewState=pending、proposalArtifactRef=null,不声称方案已正式批准或已建立报告Pointer。
+
+字段落点符合当前服务实现:concept输入的13条pendingQuestions保存在项目meta.pendingQuestions及保存回执,报告另有meta.workshop.pendingQuestions;KfConcept本身没有同名键。输入taskKey写入reportSeriesKey,不要求记录出现taskKey键。project-status的formulaItems为响应顶层数组,不要求项目记录出现同名字段;独立配方条目数为0。这些不作为数据丢失,也不补写旧记录。
+
+报告实际5章15节、5张表,修正回报中的“13节/4个table”说法:
+
+| 表 | 列×行 |
+|---|---|
+| 设计对照 | 4×4 |
+| 设计、观察与未知 | 3×8 |
+| 主引文 | 3×4 |
+| 继续、调整与暂停 | 4×4 |
+| 待补问题 | 3×13 |
+
+13个待补问题逐字等于模板;前8条沿用S4,后5条为设计问题。可见表第三列前8行是“原S4问题”,后5行分别为“承原1、4”“承原4、6”“承原7”“承原8”“承原3、6”,不写成统一“新增设计问题”。executive仅由服务端前置课堂声明,report.title/generatedAt属于预期服务补充。
+
+四条来源及证据逐字核对,编号保留下划线:
+
+| 用途 | sourceId | evidenceId |
+|---|---|---|
+| cq-03历史份量自述 | m1OQx9aynA | _9mUml0wmh |
+| cq-04历史份量自述 | QdeMcyTTjX | 2bFGP7axMo |
+| P1苏宁公开图片页观察 | PjORcvaRHn | xHGV_mRWcG |
+| P2历史个人煮制叙述 | 5S_Y4IgT5q | CeAEIwymTa |
+
+四条productRef均为空,不合并同SKU或同批次,不认定当前在售、真实购买身份或普遍需求。P1不证明信息分散,P2不证明包装错误;信息布局与可选搭配仍是待验证设计,不能称已验证卖点。
+
+## 项目及页面
+
+项目恰7条有序事件,没有completed事件:
+
+| 序号 | eventId | eventType |
+|---|---|---|
+| 1 | GGDTcg5YTy | project_created |
+| 2 | RkkTTE_xeb | task_submitted(S3) |
+| 3 | hOFghbcMsY | stage_changed(S4) |
+| 4 | VlGFu_jv4b | task_submitted(S4) |
+| 5 | 0ya0a03U9G | stage_changed(S5) |
+| 6 | qKnPnVkZA8 | concept_saved |
+| 7 | iUs3JwZ9Kf | task_submitted(S5) |
+
+同工作坊现有9份报告,S5系列只有第1版;没有第二套机会卡、项目或概念。旧S1—S4正文、结构与原有待补问题集仍保持原样,当前项目13条未决项不覆盖旧报告的8条。
+
+匿名生产浏览器核实了成员项目行、阶段页、报告列表及报告详情。成员项目行的模型/桌面显示对应S5、3份成果、方案已保存、进行中;手机折叠行展示项目名和S5进行中,不把隐藏列写成全在首屏可见。两种屏宽逐表对照所有行列,滚动检查可达性,真实点击4条引文、核逐字文本和原来源,Escape关闭。引文比较保留原换行,仅忽略显示插入的U+2009细空格。未发现页面级横向溢出、运行或网络错误,旧报告仍可读。
+
+保留显示改进:390px四列表格列窄、需较多纵向滚动;商品页证据抽屉仍沿用“—人独立提到”的人数未知标签(不是1人);手机抽屉长出处需内部横滚。来源内容可达,功能通过;本轮未改前端,也不声称这些显示问题已经修复。
+
+boardVisible=true、formalReportPublished=false、employeeConfirmed=false由实际成功保存审计核实。dashboardState=workshop不等于正式发布。browserVerified=true仅记录在本地Codex验收证据,未回写生产。enterpriseApproval=false。
+
+本次交付完成S5保存与只读独立验收;当前仍S5/in_progress。下一步可整理课堂总结和完成条件,另行执行对应状态操作;本轮不自动completed、不代员工正式确认或发布。实物研究需要时再明确样品、资源和方法,不要求用户先补实物才能继续软件流程。
+
+本地证据(均不含凭据或浏览器档案):
+
+- F:/kangshifu-board/work/live-S5-independent-acceptance-20261103.json
+- F:/kangshifu-board/work/live-S5-readback-20261103.json(首次严格验收失败,保留)
+- F:/kangshifu-board/work/S5-write-audit-inspection-20261103.json(实际成功键定位)
+- F:/kangshifu-board/work/live-S5-readback-20261103-02.json(14项数据核对)
+- F:/kangshifu-board/work/live-S5-browser-verified-20261103.json(235项及22张截图索引)
+
+01、02及README更新前完整快照保留在F:/kangshifu-board/work/S5-progress-before-acceptance-20261103/。52/53/54号、三个固定模板、原S5包及所有旧回执保留原样。
+'''
+
+progress = '''工作坊进展(截至2026年11月3日,最新独立验收见55号)
+
+真实资料课堂流程已推进到S5 / in_progress,选定方向为“燃魂份量与用餐目的适配”。概念QO0uo8nisk和S5报告tLkz2MAoIw均已保存,Codex独立数据核对14项、电脑/手机生产浏览器检查235项通过,并已目视复核代表截图。课堂仍进行中,未标完成、未员工确认、未正式发布、未企业批准;实物、市场和量产验证均未做。
+
+会话btrFqkz0yw;课堂机会卡QE9hjtCj8E;项目S_CikxZeBs。项目恰3份成果:S3基线XF4h-Z7l5o、S4评估U3_W7WDliP、S5方案tLkz2MAoIw;当前概念1个、事件7条。报告正文3655码点,5章15节5张表,4来源/4引文、13待补问题与固定模板一致,旧S1—S4保持原样。
+
+必须保留的执行偏差:54号规定S5报告保存requestId为KSF-真实资料-20261102-01:S5:save:v1,实际成功键为KSF-真实资料-20261102-01:portion-fit-project:S5:save:v1。实际键与报告/正文编号已核实;规定键没有成功提交审计。不得用规定键补跑保存,不重存、不换键、不删记录,也不回改原执行单掩盖差异。原因尚未查明;下次交接附实际第06步命令与原回执即可,不必重跑业务。
+
+本轮S5成功写命令是3个(转阶段、保存概念、保存报告);8步执行单另有5个读命令,事件为写命令副作用,不另计第4个写命令。远端文件数量/权限、CLI退出码和invocationId依据王刚001回报,Codex独立确认的是生产数据、成功写入审计与匿名页面。
+
+方案是信息呈现与可选搭配设计,尚未确定新品规格。净含量、面饼重量、件数和当前SKU等未知值保留null,配方/工艺为空。历史帖子仅证明个体自述,P1公开图片页仅证明页面/图示展示;不合并同SKU、不证明当前在售或普遍需求。13待补含原S4八项和新增五项设计问题。
+
+保留显示改进:手机四列表格较窄、需纵向滚动;商品页抽屉仍用人数未知标签;手机长出处需抽屉内横滚。功能可用,本轮未改前端。browserVerified=true仅为本地验收记录,未回写生产。
+
+下一步可整理课堂总结与完成条件,再执行相应状态操作;本次没有自动completed。真实研究缺口继续保留,不要求用户先供实物才能继续软件课堂流程。员工实名绑定、新数字人接入及旧机器人密钥轮换仍待处理。
+
+最新S5报告:https://kangshifu.fmode.cn/report/tLkz2MAoIw
+S4桌面评估:https://kangshifu.fmode.cn/report/U3_W7WDliP
+看板:https://kangshifu.fmode.cn/
+
+详细验收:55-S5方案与课堂报告独立验收结果.md。
+更新前汇报快照:F:/kangshifu-board/work/S5-progress-before-acceptance-20261103/。
+S5固定材料包及52/53/54号保留原样,不再次执行。
+'''
+
+needs = originals['02-后续待补资料.md'].decode('utf-8-sig')
+old_intro = '2026年11月3日更新。课堂系统当前S4 / in_progress;S4报告U3_W7WDliP的数据与电脑/手机功能已独立验收,详见50号。下一步可写S5桌面方案草稿;下面研究缺口不作为全部软件流程测试的统一前置,不要求用户现在提供原件,也不重查已知登录墙。'
+assert old_intro in needs
+needs = needs.replace(old_intro, '2026年11月3日更新。课堂系统当前S5 / in_progress;概念QO0uo8nisk和S5报告tLkz2MAoIw已保存,数据14项、电脑/手机生产浏览器235项检查通过,详见55号。报告保存键存在已留档偏差,不补跑保存。下面研究缺口继续保留,不作为全部软件流程测试的统一前置,不要求用户现在提供原件,也不重查已知登录墙。', 1)
+start = needs.index('软件流程下一步:')
+end = needs.index('\n\n前端两项独立待改:', start)
+needs = needs[:start] + '''软件流程状态:S5保存与独立验收完成,项目恰3报告、1概念、7事件;仍S5/in_progress,尚未课堂完成、确认或发布。当前方案未知规格为null、formulaItems/productionSteps为空。13条未决问题中前8条沿用S4,新增5条设计问题如下:
+
+1. 净含量/面饼/件数三行分区能否正确理解,会不会更乱。
+2. 信息呈现是否增加货架识别、比价或停留负担。
+3. 包材版面和渠道素材能否执行,实际成本未知。
+4. 可选搭配会不会被读成必须添加,反向暗示份量不足。
+5. 记录补主食原因会不会影响行为,记录负担和观察者效应待评估。
+
+下一步可整理课堂总结与完成条件;不重跑53/54号,不补存新版本。第06步实际成功requestId为KSF-真实资料-20261102-01:portion-fit-project:S5:save:v1,与54号规定的KSF-真实资料-20261102-01:S5:save:v1有差异。实际报告已存在,严禁用规定键补跑;只需下次交接附原命令与回执说明原因,不要求重新执行。员工正式确认和发布另走各自流程。''' + needs[end:]
+needs = needs.replace('前端两项独立待改:商品页证据抽屉的人数标签、390px长出处自动换行。功能验收通过,本轮未改前端。', '前端独立待改:商品页证据抽屉的人数未知标签、390px长出处自动换行,以及窄屏四列表格阅读体验。功能验收通过,本轮未改前端。')
+needs = needs.replace('更新前资料清单完整快照位于F:/kangshifu-board/work/S4-progress-before-acceptance-20261103/。', '本轮更新前完整快照位于F:/kangshifu-board/work/S5-progress-before-acceptance-20261103/;此前S4快照仍保留。')
+
+readme = originals['README.md'].decode('utf-8-sig')
+start = readme.index('当前状态(')
+end = readme.index('\n\n以下编号材料', start)
+readme = readme[:start] + '当前状态(2026年11月3日):S5 / in_progress。概念QO0uo8nisk、报告tLkz2MAoIw已保存;Codex14项数据和235项匿名电脑/手机生产浏览器检查通过,详见55号。项目3报告、1概念、7事件,未课堂完成、员工确认或正式发布,未做实物/市场/量产验证。第06步实际报告保存键较54号多了:portion-fit-project,偏差已留档;不得用规定键补跑保存。52/53/54及原包保留,不重跑。下一步可整理课堂总结和完成条件。' + readme[end:]
+readme = readme.replace('旧01/02/README完整快照在F:/kangshifu-board/work/S4-progress-before-acceptance-20261103/。', '本轮旧01/02/README完整快照在F:/kangshifu-board/work/S5-progress-before-acceptance-20261103/;S4旧快照仍保留。', 1)
+readme = readme.replace('- 51-发给王刚001-S5桌面方案草稿.txt:', '- 55-S5方案与课堂报告独立验收结果.md:当前结论;内容和生产页面通过,保存requestId偏差、计数及字段落点已校正,保持S5/in_progress,不补跑保存。\n- 51-发给王刚001-S5桌面方案草稿.txt:', 1)
+readme = readme.replace('- 52-S5桌面方案审阅与定稿.md:当前审阅结果;', '- 52-S5桌面方案审阅与定稿.md:历史审阅结果;', 1)
+readme = readme.replace('- 53-发给运维001-S5方案与报告准备.txt:当前下一步;原样落地3模板并只读核合同、归属、来源、历史、在途。', '- 53-发给运维001-S5方案与报告准备.txt:已回报落地预检完成,原单保留,不重跑准备。', 1)
+readme = readme.replace('- 54-发给王刚001-S5方案与课堂报告保存.txt:运维预检通过后在真实消息执行8步;project-progress→concept-save→workshop-save,最终S5/in_progress,不自动completed。', '- 54-发给王刚001-S5方案与课堂报告保存.txt:已执行并保存S5;第06步实际成功requestId与原单不同,见55号。保留原单不回改,不补跑规定键,不自动completed。', 1)
+readme = readme.replace('- S5桌面方案第1版-审阅稿.md:拟保存的正文审阅稿,尚未保存生产。', '- S5桌面方案第1版-审阅稿.md:已保存内容的审阅稿,生产报告tLkz2MAoIw及回读为准。', 1)
+readme = readme.replace('- ksf-S5-classroom-next-20261103.zip:3固定模板、runbook、审阅及交接;11条目、10/10清单核验。sessionId/projectId为null,不可直接提交。', '- ksf-S5-classroom-next-20261103.zip:已使用的3模板与交接原包,11条目、10/10核验;指纹未变,不再次执行。ID绑定为null,不可直接提交。', 1)
+readme = readme.replace('- 50-S4课堂桌面评估独立验收结果.md:当前结论;', '- 50-S4课堂桌面评估独立验收结果.md:S4阶段历史结论;', 1)
+readme = readme.replace('当前入口:\n\n', '当前入口:\n\n- 最新S5桌面方案:https://kangshifu.fmode.cn/report/tLkz2MAoIw\n', 1)
+readme = readme.replace('- 最新S4桌面评估:', '- S4桌面评估:', 1)
+
+updates = {'01-工作进展汇报.txt': progress, '02-后续待补资料.md': needs, 'README.md': readme}
+for name, text in updates.items():
+    assert 'S5 / in_progress' in text
+    assert 'tLkz2MAoIw' in text
+    assert 'portion-fit-project:S5:save:v1' in text or name == 'README.md'
+assert '尚未保存concept或S5报告' not in readme
+assert '当前下一步;原样落地3模板' not in readme
+
+BACKUP.mkdir()
+for name, content in originals.items():
+    with (BACKUP / name).open('xb') as stream:
+        stream.write(content)
+with (BACKUP / 'manifest.json').open('x', encoding='utf-8') as stream:
+    json.dump({name: sha256(content).hexdigest() for name, content in originals.items()}, stream, ensure_ascii=False, indent=2)
+with RECEIPT.open('x', encoding='utf-8') as stream:
+    json.dump(receipt, stream, ensure_ascii=False, indent=2)
+with REPORT.open('x', encoding='utf-8') as stream:
+    stream.write(report)
+for name, content in updates.items():
+    (DOCS / name).write_text(content, encoding='utf-8')
+for path, expected in immutable.items():
+    assert digest(path) == expected, path
+for name, content in originals.items():
+    assert (BACKUP / name).read_bytes() == content
+print(json.dumps({'ok': True, 'businessWrites': 0, 'acceptance': str(REPORT), 'receipt': str(RECEIPT), 'backup': str(BACKUP), 'updated': list(updates), 'immutableFilesUnchanged': len(immutable)}, ensure_ascii=False, indent=2))

+ 201 - 0
scripts/update-S5-completion-acceptance.py

@@ -0,0 +1,201 @@
+"""Write local final acceptance and progress; no production mutations."""
+from pathlib import Path
+from datetime import datetime, timezone
+from hashlib import sha256
+import json
+
+ROOT = Path('F:/kangshifu-board')
+DOCS = Path('F:/文档/工作坊当前资料')
+WORK = ROOT / 'work'
+BACKUP = WORK / 'S5-progress-before-completion-acceptance-20261103'
+RECEIPT = WORK / 'live-S5-completion-independent-acceptance-20261103.json'
+REPORT = DOCS / '59-课堂完成独立验收与偏差补记.md'
+data_file = WORK / 'live-S5-completion-readback-20261103-02.json'
+browser_file = WORK / 'live-S5-completion-browser-verified-20261103.json'
+data = json.loads(data_file.read_text(encoding='utf-8'))
+browser = json.loads(browser_file.read_text(encoding='utf-8'))
+assert data['ok'] and browser['ok']
+assert data['checksPassed'] == 9 and len(browser['checks']) == 77
+assert all(c['passed'] for c in browser['checks']) and not browser['errors']
+assert data['progressState'] == browser['progressState'] == 'completed'
+assert data['eventId'] == 'MV6JyC6Jcx' and data['pendingQuestionCount'] == 13
+assert not REPORT.exists() and not RECEIPT.exists() and not BACKUP.exists()
+digest = lambda path: sha256(path.read_bytes()).hexdigest()
+old_names = ['52-S5桌面方案审阅与定稿.md', '53-发给运维001-S5方案与报告准备.txt', '54-发给王刚001-S5方案与课堂报告保存.txt', '55-S5方案与课堂报告独立验收结果.md', '56-课堂总结与完成条件.md', '57-发给运维001-课堂完成准备.txt', '58-发给王刚001-课堂完成与回读.txt', 'ksf-S5-classroom-next-20261103.zip', 'ksf-S5-classroom-completion-20261103.zip']
+old_hashes = {name: digest(DOCS / name) for name in old_names}
+originals = {name: (DOCS / name).read_bytes() for name in ['01-工作进展汇报.txt', '02-后续待补资料.md', 'README.md']}
+visual = [path for path in browser['screenshots'] if any(k in path for k in ['project-desktop', 'project-mobile', 'report-mobile', 'pending-mobile'])]
+assert len(visual) == 4
+
+receipt = {
+    'ok': True, 'at': datetime.now(timezone.utc).isoformat(),
+    'operation': 'live-S5-classroom-completion-independent-acceptance',
+    'classroomCompletionAccepted': True, 'stageCode': 'S5', 'progressState': 'completed',
+    'status': 'workshop', 'dataMode': 'live', 'projectId': data['projectId'],
+    'sessionId': data['sessionId'], 'opportunityId': data['opportunityId'],
+    'conceptId': data['conceptId'], 'artifactId': data['artifactId'],
+    'outputSourceId': data['outputSourceId'], 'completionEventId': data['eventId'],
+    'projectArtifactIds': data['projectArtifactIds'], 'projectEventCount': 8,
+    'projectEvents': data['projectEvents'], 'conceptCount': 1, 'conceptVersion': 1,
+    'pendingQuestionCount': 13, 'completionRequestId': data['completionRequestId'],
+    'committedCompletionAuditCount': 1, 'completionWriteKeyMatchesRunbook': True,
+    'dataChecks': 9, 'browserChecks': 77, 'browserVerified': True,
+    'browserVerificationStoredLocallyOnly': True, 'viewports': [1440, 390],
+    'readOnly': True, 'businessWrites': 0, 'oldReportsPreserved': True,
+    'historicalS5RequestIdDeviations': data['historicalS5RequestIdDeviations'],
+    'historicalExecutionFullyConformsToRunbook': False,
+    'formalReportPublished': False, 'employeeConfirmed': False, 'enterpriseApproval': False,
+    'physicalMarketOrProductionValidationPerformed': False,
+    'remoteFilesInvocationIdsExitcodesAndIdentityBasis': 'Operator reports only; Codex has not independently read the remote filesystem or original CLI receipts.',
+    'timestampCheckCorrection': data['timestampCheckCorrection'],
+    'visualReview': {'performed': True, 'screenshots': visual},
+    'evidenceFiles': [{'path': str(p), 'sha256': digest(p)} for p in [data_file, browser_file, WORK / 'live-S5-completion-readback-20261103.json']],
+    'screenshots': [{'path': str(ROOT / p), 'sha256': digest(ROOT / p)} for p in browser['screenshots']],
+    'retainedHistoricalDocuments': old_hashes, 'documentationBackup': str(BACKUP),
+}
+
+report = '''# 课堂完成独立验收与偏差补记
+
+2026年11月3日。Codex独立读取生产项目、完成事件、成功写入审计及成果,并使用新的匿名生产浏览器核对电脑/手机页面。确认本次课堂收尾成功,项目为S5 / completed;历史执行偏差另行保留。本轮Codex没有业务写入。
+
+| 项目 | 独立核实结果 |
+|---|---|
+| 项目 | S_CikxZeBs;stageCode=S5、progressState=completed、status=workshop、dataMode=live |
+| 会话 / 机会卡 | btrFqkz0yw / QE9hjtCj8E;归属与互链保持一致,机会仍为hypothesis |
+| 当前概念 | QO0uo8nisk,第1版,reviewState=pending |
+| 项目成果 | 恰3份:XF4h-Z7l5o(S3)、U3_W7WDliP(S4)、tLkz2MAoIw(S5) |
+| 完成事件 | MV6JyC6Jcx,sequence=8,eventType=completed,fromStage/toStage均S5 |
+| 完成写键 | KSF-真实资料-20261102-01:portion-fit-project:complete:v1;成功审计恰1条,与58号一致 |
+| 未决问题 | 13条逐字保留;旧报告各自的问题集未覆盖 |
+| 数据检查 | 9项通过;最终一轮13次生产只读调用(含匿名公开投影) |
+| 生产浏览器 | 1440px电脑及390px手机,77项检查通过;8张截图,4张代表截图目视复核 |
+| 确认 / 发布 / 企业批准 | 均未发生;enterpriseApproval=false |
+| 实物 / 市场 / 量产验证 | 均未发生 |
+
+[S5方案报告](https://kangshifu.fmode.cn/report/tLkz2MAoIw) · [项目进度页](https://kangshifu.fmode.cn/members)。
+
+## 完成状态与留存
+
+本次完成成功审计的operation=workshop-transition,receipt准确对应项目S_CikxZeBs、事件MV6JyC6Jcx、S5/completed及enterpriseApproval=false。完成事件的note逐字等于510字节固定模板,projectRef/sessionRef准确,未关联新artifact;原7条事件的编号与顺序不变。会话step=completed,项目status仍为workshop,不能要求其变成completed。
+
+| 序号 | eventId | eventType |
+|---|---|---|
+| 1 | GGDTcg5YTy | project_created |
+| 2 | RkkTTE_xeb | task_submitted(S3) |
+| 3 | hOFghbcMsY | stage_changed(S4) |
+| 4 | VlGFu_jv4b | task_submitted(S4) |
+| 5 | 0ya0a03U9G | stage_changed(S5) |
+| 6 | qKnPnVkZA8 | concept_saved |
+| 7 | iUs3JwZ9Kf | task_submitted(S5) |
+| 8 | MV6JyC6Jcx | completed |
+
+同会话机会卡和项目各1个,同项目概念1个;同工作坊报告仍9份,S5只有第1版。S5正文3655码点、5章15节5张表、4来源/4引文、13问题和固定模板相同;正文来源5zsPtYFL1u没有变化。既有S1—S4相应版本正文/章节/引文/问题集仍与各原模板一致。概念未知规格四项null、空配方/工艺、pending审阅状态保持原样。当前概念指针在status响应的project.currentConceptRef,概念记录在响应顶层concept对象;不能从响应顶层取currentConceptRef。
+
+本次58号三个CLI命令为1写2读,完整调用编号、12文件/权限、stderr和退出码依据王刚001回报;Codex没有独立读取远端文件或原始CLI回执。独立数据核实了完成写键及成功审计,不用写审计表证明读命令的历史调用次数。
+
+## 补记两处S5历史请求键偏差
+
+| 54号步骤 | 规定requestId | 实际requestId | 核查依据 |
+|---|---|---|---|
+| 06 workshop-save | KSF-真实资料-20261102-01:S5:save:v1 | KSF-真实资料-20261102-01:portion-fit-project:S5:save:v1 | Codex独立核成功写入审计;55号已记录 |
+| 07 workshop-read | KSF-真实资料-20261102-01:S5:read:v1 | KSF-真实资料-20261102-01:portion-fit-project:S5:read:v1 | 据运维001对远端回执的核查回报;Codex未独立取得该回执 |
+
+55号当时独立核查覆盖写入审计,不能据此覆盖读请求键。本次按运维回报补记第07步,并保留证据等级;“两步共同使用项目前缀”是所报回执呈现的形态,变化原因仍未知。不得写成8步全部按规定键执行,也不将运维的原始回执核查升级为Codex独立读取。
+
+两处偏差不通过重跑来修复:不补存、不补跑旧读命令、不删记录、不换键,不回改54/55/56号、原模板或包。58号本次完成写键独立核实与规定一致;课堂完成不抹去历史偏差。
+
+## 页面和字段口径
+
+两种屏宽的匿名生产成员页均显示该项目“S5 · 课堂完成”;电脑显示3份成果和方案已保存,手机折叠行可见项目名和课堂完成。公开数据模型指向同一概念、同一3报告。阶段页、报告列表、S5正文/13问题可见表、原来源引文抽屉及旧S4仍可打开,无运行/网络错误或页面级横向溢出;浏览器只调用匿名公开看板读取,无私表扫描或服务凭据。
+
+S5报告中“S5/in_progress”“本轮不包含completed”保留提交当时的描述,报告生成于完成事件之前;当前项目状态另由进度页显示completed。本次不为追改历史描述新存报告。
+
+browserVerified=true仅存本地独立验收证据,未回写生产。正式发布/员工确认布尔值由S5成功保存回执核为false;dashboardPublishedAt是记录上的发布时间字段,目前为null,不能把它说成“只存在保存响应”。完成操作不调用正式确认/发布流程。
+
+保留三项显示改进:手机四列表格较窄;商品页引文沿用“—人独立提到”的人数未知标签;手机长出处需抽屉内横滚。本轮没有改前端,不能说这些已修复。
+
+首次完成验收脚本误将旧回报的generatedAt=2026-11-03T04:36:12.012Z作为固定值,检查在此停止。实际现网为2026-11-03T05:19:50.402Z,浏览器显示相应本地时间13:19。此前独立验收未留存该字段原值,因此本次不能证明它何时变化,也不能用旧回报断言生产被改写。已改为校验实际时间有效、正文/章节等对固定模板逐字比较,首次结果保留;未因此执行任何业务重试。
+
+## 完成范围和后续
+
+S1至S5公开资料课堂流程已走完并留档,包含个体历史线索、待验证假设、桌面评估和待验证方案。13项研究/设计问题继续保留,需求普遍性、支付意愿、当前SKU、实物效果和企业落地依据仍未解决。
+
+本轮可以结束课堂链;无需再转阶段、重复保存或再次完成。后续若开展真实验证,可从信息理解或份量行为研究另定目标、对象、资源和预先判据;正式报告确认/发布另走真人流程。员工实名绑定、新数字人接入及旧机器人密钥轮换仍是独立待办。
+
+本地证据:
+
+- F:/kangshifu-board/work/live-S5-completion-independent-acceptance-20261103.json
+- F:/kangshifu-board/work/live-S5-completion-readback-20261103.json(首次时间假设检查失败,保留)
+- F:/kangshifu-board/work/live-S5-completion-readback-20261103-02.json(最终9项核对)
+- F:/kangshifu-board/work/live-S5-completion-browser-verified-20261103.json(77项及8张截图索引)
+
+01、02及README本轮更新前完整快照:F:/kangshifu-board/work/S5-progress-before-completion-acceptance-20261103/。52—58号和两个S5固定包原字节保留,未覆盖历史结论或回执。
+'''
+
+progress = '''工作坊进展(截至2026年11月3日,最终课堂验收见59号)
+
+真实资料课堂链已完成:项目S_CikxZeBs为S5 / completed、status=workshop、dataMode=live。选定方向“燃魂份量与用餐目的适配”。Codex独立核对完成状态9项、电脑/手机生产浏览器77项通过,并目视复核完成态截图。本轮Codex只读,没有业务写入。
+
+会话btrFqkz0yw;课堂机会卡QE9hjtCj8E;当前概念QO0uo8nisk第1版。项目仍恰3份报告:XF4h-Z7l5o(S3)、U3_W7WDliP(S4)、tLkz2MAoIw(S5)。事件恰8条,新增末条MV6JyC6Jcx为completed;完成写键KSF-真实资料-20261102-01:portion-fit-project:complete:v1与58号一致,成功审计恰1条。旧报告和13项研究/设计未决问题全部保留。
+
+课堂完成不表示需求已验证、产品可量产、企业批准、员工确认或正式发布。实物、市场、量产验证均未做;规格未知值null、配方和工艺数组为空仍是资料缺口。历史帖子只证明个体这样说,商品图片页只证明页面展示,不合并同SKU或推定当前在售。
+
+S5历史执行有两处请求键偏差:第06保存实际为…:portion-fit-project:S5:save:v1,规定为…:S5:save:v1(Codex独立核成功审计);第07读取实际为…:portion-fit-project:S5:read:v1,规定为…:S5:read:v1(据运维对远端回执的核查回报,Codex未取得原回执)。变化原因未知,已在59号补记;不补跑、不补存、不删记录,不回改54/55号及原包。课堂收尾不声称全程完全符合执行单。
+
+本次58号按回报执行1写2读,退出码、调用编号及远端12文件权限依据王刚001回报;独立验收证实生产终态、事件/审计及匿名页面。首次独立检查用了旧回报生成时间而停止,已更正检查口径,原结果保留;当前report.generatedAt为2026-11-03T05:19:50.402Z,不推断历史变化。
+
+公开看板电脑/手机均显示课堂完成,S5原报告仍保留提交时的in_progress描述,不追改历史。手机窄表、人数未知标签与长出处横滚问题未修复。browserVerified=true只为本地本轮证据,未回写生产。
+
+课堂链可结束,无需再转阶段或保存。后续是真实研究与正式确认/发布的各自流程;13个未决问题见02/56/59号。员工实名绑定、新数字人接入和旧机器人密钥轮换仍为独立待办。
+
+项目进度:https://kangshifu.fmode.cn/members
+S5报告:https://kangshifu.fmode.cn/report/tLkz2MAoIw
+S4报告:https://kangshifu.fmode.cn/report/U3_W7WDliP
+最终验收:59-课堂完成独立验收与偏差补记.md
+更新前快照:F:/kangshifu-board/work/S5-progress-before-completion-acceptance-20261103/。
+52—58号及S5两个固定包保留,不再次执行。
+'''
+
+needs = originals['02-后续待补资料.md'].decode('utf-8-sig').replace('\r\n', '\n')
+start = needs.index('2026年11月3日更新。')
+end = needs.index('\n\n| 研究事项', start)
+needs = needs[:start] + '2026年11月3日更新。课堂系统当前S5 / completed;完成事件MV6JyC6Jcx、3报告/1概念/8事件/13问题已独立核对,电脑/手机完成态页面通过77项检查,详见59号。课堂链走完,研究未完成;以下资料缺口全部继续保留,不要求用户现在提供原件,也不重查已知登录墙。历史第06/07步请求键偏差已补记,不补跑。' + needs[end:]
+start = needs.index('软件流程状态:')
+end = needs.index('\n\n1.', start)
+needs = needs[:start] + '软件流程状态:课堂链已完成并独立验收,项目仍3报告、1概念,事件由7增为8,13问题不变。方案规格未知值null、formulaItems/productionSteps为空,均不视为研究结论。13条问题前8条沿用S4,新增5条设计问题如下:' + needs[end:]
+start = needs.index('56号已整理课堂总结')
+end = needs.index('\n\n前端独立待改:', start)
+needs = needs[:start] + '56号总结及57/58号收尾已执行,59号为最终独立验收。无需再转阶段、完成或保存;不重跑53/54/57/58号,不补存新版本。历史第06步保存键偏差由Codex成功审计核实,第07步读取键偏差依据运维回执核查回报,原因未知;不以再次执行修正。正式员工确认/发布、真实研究与企业落地分别推进;当前13项不因课堂完成而关闭。' + needs[end:]
+needs = needs.replace('本轮更新前完整快照位于F:/kangshifu-board/work/S5-progress-before-acceptance-20261103/;此前S4快照仍保留。', '本轮更新前完整快照位于F:/kangshifu-board/work/S5-progress-before-completion-acceptance-20261103/;此前快照仍保留。')
+
+readme = originals['README.md'].decode('utf-8-sig').replace('\r\n', '\n')
+start = readme.index('当前状态(')
+end = readme.index('\n\n以下编号材料', start)
+readme = readme[:start] + '当前状态(2026年11月3日):S5 / completed,课堂链已完成并独立验收,详见59号。项目S_CikxZeBs,3报告/1概念/8事件/13未决问题;完成事件MV6JyC6Jcx。9项数据、77项匿名电脑/手机生产浏览器检查通过。尚未员工确认、正式发布或企业批准,实物/市场/量产验证均未做。历史S5第06/07步请求键偏差分别按独立审计/运维回执回报补记;不补跑、不补存、不再完成。后续按实际研究或正式确认/发布各自推进。' + readme[end:]
+readme = readme.replace('本轮旧01/02/README完整快照在F:/kangshifu-board/work/S5-progress-before-acceptance-20261103/;S4旧快照仍保留。', '本轮旧01/02/README完整快照在F:/kangshifu-board/work/S5-progress-before-completion-acceptance-20261103/;此前快照仍保留。', 1)
+readme = readme.replace('- 56-课堂总结与完成条件.md:当前收尾范围,', '- 59-课堂完成独立验收与偏差补记.md:当前最终结论;课堂completed、9项数据/77项生产浏览器通过,13问题保留,补记第07步读取键偏差。\n- 56-课堂总结与完成条件.md:历史收尾范围,', 1)
+readme = readme.replace('- 57-发给运维001-课堂完成准备.txt:下一步;落地1份固定模板、核合同/归属/状态/回执/在途,不代跑完成。', '- 57-发给运维001-课堂完成准备.txt:已回报落地及预检通过,保留不重跑。', 1)
+readme = readme.replace('- 58-发给王刚001-课堂完成与回读.txt:57号通过后在真实消息执行3步;project-status→project-progress→project-status,1写2读,只更新课堂完成状态。', '- 58-发给王刚001-课堂完成与回读.txt:已执行1写2读,完成态经59号独立验收,不再执行。', 1)
+readme = readme.replace('- ksf-S5-classroom-completion-20261103.zip:9条目、清单8/8;模板两ID为null不可直接提交,生产未完成。', '- ksf-S5-classroom-completion-20261103.zip:已使用的收尾原包,9条目、8/8核验;保留原样,不再次执行。', 1)
+readme = readme.replace('- 55-S5方案与课堂报告独立验收结果.md:当前结论;', '- 55-S5方案与课堂报告独立验收结果.md:S5保存阶段历史结论;', 1)
+readme = readme.replace('当前入口:\n\n', '当前入口:\n\n- 已完成项目进度:https://kangshifu.fmode.cn/members\n', 1)
+
+updates = {'01-工作进展汇报.txt': progress, '02-后续待补资料.md': needs, 'README.md': readme}
+for name, text in updates.items():
+    assert 'completed' in text and 'MV6JyC6Jcx' in text and '13' in text
+    assert '未执行完成写入' not in text
+BACKUP.mkdir()
+for name, content in originals.items():
+    with (BACKUP / name).open('xb') as stream:
+        stream.write(content)
+with (BACKUP / 'manifest.json').open('x', encoding='utf-8') as stream:
+    json.dump({n: sha256(b).hexdigest() for n, b in originals.items()}, stream, ensure_ascii=False, indent=2)
+with RECEIPT.open('x', encoding='utf-8') as stream:
+    json.dump(receipt, stream, ensure_ascii=False, indent=2)
+with REPORT.open('x', encoding='utf-8') as stream:
+    stream.write(report)
+for name, text in updates.items():
+    (DOCS / name).write_text(text, encoding='utf-8', newline='\n')
+for name, expected in old_hashes.items():
+    assert digest(DOCS / name) == expected
+print(json.dumps({'ok': True, 'businessWrites': 0, 'acceptance': str(REPORT), 'receipt': str(RECEIPT), 'progressState': 'completed', 'oldFilesUnchanged': len(old_hashes), 'updated': list(updates)}, ensure_ascii=True, indent=2))

+ 88 - 0
scripts/verify-S4-classroom-contract.mjs

@@ -0,0 +1,88 @@
+// Production access is read only: function source, contract, write audit and current scope.
+import fs from 'node:fs';
+import crypto from 'node:crypto';
+import assert from 'node:assert/strict';
+import {invoke,classRows} from '../server/scripts/platform-client.mjs';
+
+const output='work/S4-classroom-contract-check-20261103-03.json';
+assert.equal(fs.existsSync(output),false,'Preserve prior audit');
+const taskBase='KSF-真实资料-20261102-01',sessionId='btrFqkz0yw';
+const normalize=s=>s.replace(/\r\n/g,'\n');
+const workshop=normalize(fs.readFileSync('server/src/workshop.mjs','utf8').replace(/^export /gm,''));
+const core=normalize(fs.readFileSync('server/src/core.mjs','utf8').replace(/^export /gm,''));
+const names=['ksfSaveWorkshopTask','ksfCreateProjectFromOpportunity','ksfTransitionProject','ksfGetWorkshopProject'];
+const block=(text,start,end)=>{
+ const at=text.indexOf(start);assert.ok(at>=0);const finish=text.indexOf(end,at+start.length);assert.ok(finish>at);
+ return text.slice(at,finish).trim();
+};
+const relevantBlocks=[
+ ...names.map(name=>({name,code:block(workshop,'app.handlers.'+name+'=',name==='ksfGetWorkshopProject'?'\n  const fields=':'\n  app.handlers.')})),
+ {name:'reportOf',code:block(workshop,'function reportOf(','\n  app.handlers.ksfSaveWorkshopTask=')},
+ {name:'session/project/event helpers',code:block(workshop,'const own=','\n  const contract=app.handlers.ksfGetIngestContract;')},
+ {name:'write idempotency operation',code:block(core,'async function operation(','\n  function validateValue(')}
+];
+const handoff=fs.readFileSync('F:/文档/工作坊当前资料/47-发给王刚001-S4课堂项目与桌面评估.txt','utf8');
+const requestIds=[...handoff.matchAll(/requestId=(KSF-[^\s。;]+)/gu)].map(m=>m[1]);
+assert.equal(requestIds.length,12);assert.equal(new Set(requestIds).size,12);
+const series=['S3-份量候选','S3-项目基线','S4'].map(s=>taskBase+':'+s);
+const pointer={__type:'Pointer',className:'KfSession',objectId:sessionId};
+let step='read source and audit';
+try{
+ const inspected=JSON.parse(fs.readFileSync('work/S4-classroom-deployed-handler-inspection-20261103.json','utf8'));
+ const functionSnapshots=names.map(name=>{
+  const info=inspected.find(x=>x.name===name),code=fs.readFileSync('work/S4-deployed-'+name+'-20261103.js','utf8');
+  assert.equal(crypto.createHash('sha256').update(code).digest('hex'),info.codeSha256);
+  return{name,objectId:info.id,path:'/'+name,code};
+ });
+ const settled=await Promise.allSettled([
+  invoke('ksfGetIngestContract',{}),Promise.resolve(functionSnapshots),
+  classRows('KsfOperation',{requestId:{$in:requestIds}}),
+  classRows('KfArtifact',{reportSeriesKey:{$in:series}}),
+  classRows('KfOpportunity',{$or:[{opportunitySeriesKey:taskBase+':portion-fit'},{sessionRef:pointer}]}),
+  classRows('KfProject',{sessionRef:pointer}),
+  invoke('ksfGetRecord',{className:'KfSession',objectId:sessionId})
+ ]);
+ const failed=settled.flatMap((r,i)=>r.status==='rejected'?[i]:[]);assert.deepEqual(failed,[],'Read group failed (indexes only)');
+ const [contractResponse,functions,operations,artifacts,opportunities,projects,sessionResponse]=settled.map(r=>r.value);
+ assert.equal(contractResponse.http,200);assert.equal(contractResponse.body?.result?.ok,true);
+ assert.equal(sessionResponse.http,200);assert.equal(sessionResponse.body?.result?.ok,true);
+ const contract=contractResponse.body.result.data,session=sessionResponse.body.result.data.record;
+ step='deployed command implementation';
+ const sourceChecks=[];
+ for(const name of names){
+  const matches=functions.filter(x=>x.name===name);assert.equal(matches.length,1);
+  const row=matches[0],code=normalize(row.code);
+  const blockChecks=relevantBlocks.map(part=>({name:part.name,matched:code.includes(part.code)}));
+  const check={name,functionObjectId:row.objectId,path:row.path,
+   sha256:crypto.createHash('sha256').update(row.code).digest('hex'),
+   includesExactLocalWorkshopModule:code.includes(workshop),includesExactLocalCoreModule:code.includes(core),
+   relevantCommandBlocks:blockChecks};
+  sourceChecks.push(check);
+  assert.ok(blockChecks.every(x=>x.matched),name+' relevant command and helper code equality');
+ }
+ step='current scope and write history';
+ assert.equal(operations.length,0);assert.equal(artifacts.length,0);assert.equal(opportunities.length,0);assert.equal(projects.length,0);
+ assert.equal(session.stageCode,'S3');assert.equal(session.dataMode,'live');assert.equal(session.workshopKey,taskBase);
+ const reportTypes=contract.reportTypes.map(x=>x.code);
+ assert.ok(reportTypes.includes('workshop_auxiliary'));assert.ok(!reportTypes.includes('opportunity_card'));assert.ok(!reportTypes.includes('assessment_report'));
+ const result={ok:true,at:new Date().toISOString(),readOnly:true,productionBusinessWrites:0,
+  contract:{schemaVersion:contract.schemaVersion,contractRevision:contract.contractRevision,release:contract.release,
+   workshopProjectTransition:contract.capabilities.workshopProjectTransition,projectTransition:contract.capabilities.projectTransition,
+   reportTypes,workshop:contract.workshop},
+  deployedCodeChecks:sourceChecks,checkedRequestIds:requestIds,matchedWriteAuditRows:operations.length,
+  history:{newSeriesArtifacts:0,opportunities:0,projects:0},systemStage:'S3',
+  decisions:{artifactTypeSeparateFromReportType:true,reportType:'workshop_auxiliary',artifactSubtype:'classroom_task',
+   commandPayloadSeparateFromTableFields:true,projectKeyStoredInMeta:true,toStageAppliedToStageCode:true,
+   enterpriseApprovalIsResponseOnly:true,opportunityKeyBecomesSeriesKey:true,claimKindSuppliedByHandler:'hypothesis',
+   opportunityVersionInheritsReportVersion:true,statusBeforeProject:'candidate'},
+  limits:['No remote filesystem/process inspection by Codex','No claim of local CLI read-request count from KsfOperation audit',
+   'Whole-module equality check retained separately; this audit compares the relevant command bodies and helper functions',
+   'Read-only live checks do not guarantee future write outcome; stop rules remain in force']};
+ fs.writeFileSync(output,JSON.stringify(result,null,2)+'\n',{flag:'wx'});
+ console.log(JSON.stringify({ok:true,contractRevision:result.contract.contractRevision,deployedFunctionsMatched:sourceChecks.length,
+  matchedWriteAuditRows:0,newSeriesArtifacts:0,opportunities:0,projects:0,systemStage:'S3',decisions:result.decisions},null,2));
+}catch(error){
+ const result={ok:false,step,errorCode:'S4_CLASSROOM_CONTRACT_CHECK_STOPPED',readOnly:true,productionBusinessWrites:0,
+  checkLine:error.stack?.match(/verify-S4-classroom-contract\.mjs:(\d+):/)?.[1]??null};
+ fs.writeFileSync(output,JSON.stringify(result,null,2)+'\n',{flag:'wx'});console.error(JSON.stringify(result,null,2));process.exitCode=1;
+}

Some files were not shown because too many files changed in this diff