Procházet zdrojové kódy

feat: orchestrate trusted employee identity in workshop skill

pengzhikang před 1 dnem
rodič
revize
b6b3c0f3d6

+ 2 - 0
.gitattributes

@@ -0,0 +1,2 @@
+# Runtime fingerprints include the original bytes; keep them stable across OSes.
+skills/ksf-workshop-runtime/** -text whitespace=blank-at-eol,blank-at-eof,space-before-tab,cr-at-eol

+ 5 - 1
docs/wecom-identity-integration-20261103.md

@@ -35,12 +35,16 @@ node server/identity/manage.mjs sync --sessions 2InAdyzimm,btrFqkz0yw --apply --
 
 
 每步失败即停,保留stdout/stderr/exitcode和私有回执;未知写结果先核对,不自动换键。dry同步会调用企微读取接口,零数据库写入;apply只更新KsfRuntimeConfig.value.wecomEmployees及一条成功操作审计,不重存业务报告或改工作坊进度。配置修改先留私有备份,再合并当前运行配置;不覆盖signingSecret和其他设置。
 每步失败即停,保留stdout/stderr/exitcode和私有回执;未知写结果先核对,不自动换键。dry同步会调用企微读取接口,零数据库写入;apply只更新KsfRuntimeConfig.value.wecomEmployees及一条成功操作审计,不重存业务报告或改工作坊进度。配置修改先留私有备份,再合并当前运行配置;不覆盖signingSecret和其他设置。
 
 
-发布更新`ksfGetPublicBoard`(inPfC51rvn)、`ksfStartWorkshopSession`(yKpUay6RDG)、`ksfSaveWorkshopTask`(f0faHuk5Br),新增`ksfSyncWecomEmployee`。四函数均从各自现网Function.code叠加身份处理,不把本地旧版整套后端覆盖上去。各旧函数基线见包内`server/identity/dist/manifest.json`;未知版本停止。未改Skill、wrapper、Schema或ACL。
+发布更新`ksfGetPublicBoard`(inPfC51rvn)、`ksfStartWorkshopSession`(yKpUay6RDG)、`ksfSaveWorkshopTask`(f0faHuk5Br),新增`ksfSyncWecomEmployee`。四函数均从各自现网Function.code叠加身份处理,不把本地旧版整套后端覆盖上去。各旧函数基线见包内`server/identity/dist/manifest.json`;未知版本停止。r5后端/看板包未包含Skill更新,也未改wrapper、Schema或ACL;数字人编排说明现由独立Skill补充包交付,见下节。
 
 
 开始/上传只需原本的ingest:write权限:先核对服务身份、会话归属和数字人作用域,再由服务端从当前会话私有快照查姓名,并保存身份映射和审计,最后执行原业务保存。员工、模型都不传姓名作为查询结果。已启用通讯录时,错映射、无权限或查询失败均在报告写入前停止;身份查询失败不会被误写成“报告已保存”。未启用通讯录时沿用稳定发送者归属,并在回执中标`employeeIdentity.status=pending`,不能称实名已取得。原业务回执的报告ID/幂等键保留,增加`employee`及自动同步状态。
 开始/上传只需原本的ingest:write权限:先核对服务身份、会话归属和数字人作用域,再由服务端从当前会话私有快照查姓名,并保存身份映射和审计,最后执行原业务保存。员工、模型都不传姓名作为查询结果。已启用通讯录时,错映射、无权限或查询失败均在报告写入前停止;身份查询失败不会被误写成“报告已保存”。未启用通讯录时沿用稳定发送者归属,并在回执中标`employeeIdentity.status=pending`,不能称实名已取得。原业务回执的报告ID/幂等键保留,增加`employee`及自动同步状态。
 
 
 同步函数须具备identity:write或管理权限。它只接受会话编号,由服务端读取私有账号并查询企微;不接收调用者填写的姓名、部门或UserID。公开查询只读已同步档案,不公开调用企微接口。
 同步函数须具备identity:write或管理权限。它只接受会话编号,由服务端读取私有账号并查询企微;不接收调用者填写的姓名、部门或UserID。公开查询只读已同步档案,不公开调用企微接口。
 
 
+## Skill编排补充
+
+现有 `ksf-workshop-runtime` 已补充“当前消息登记 → 员工/Agent/session核对 → start/save自动查实名 → 核对回执 → 成果回读”。使用 [Skill员工识别补充升级说明](workshop-skill-employee-identity-20261103.md) 安装独立补充包;r5包、Skill包及运行配置三项配套,不能把只更新其中一项说成全链路完成。该补充只更新三份Skill说明和派生校验清单,五份运行脚本、runtime-lock、凭据绑定及已有员工档案均保留。
+
 ## 持续同步和浏览器验收
 ## 持续同步和浏览器验收
 
 
 r5开始/上传入口已自动同步,员工上传成果不依赖定时任务。另可将`node /实际包目录/server/identity/scheduled-sync.mjs`接到维护人现有的每10分钟计划任务,继承受限KSF_PLATFORM_CONFIG,仅用于历史补齐和部门/职务变化刷新。脚本按分钟业务键执行一次;错误不自动重试。业务会话创建后已有姓名,报告上传回执同步返回姓名,看板原轮询刷新展示。
 r5开始/上传入口已自动同步,员工上传成果不依赖定时任务。另可将`node /实际包目录/server/identity/scheduled-sync.mjs`接到维护人现有的每10分钟计划任务,继承受限KSF_PLATFORM_CONFIG,仅用于历史补齐和部门/职务变化刷新。脚本按分钟业务键执行一次;错误不自动重试。业务会话创建后已有姓名,报告上传回执同步返回姓名,看板原轮询刷新展示。

+ 52 - 0
docs/workshop-skill-employee-identity-20261103.md

@@ -0,0 +1,52 @@
+# 运维001:工作坊Skill员工识别补充升级
+
+员工通过企微数字人生成并上传成果时,Skill负责取得当前消息的员工档案、选择正确session、调用上传入口和核对实名结果。底层可信发送者由现有进程内工具提供;通讯录查询及权限校验由r5服务端完成;看板显示服务返回的姓名。员工无需登录网页或手填账号。
+
+本包为 `ksf-workshop-skill-employee-20261103-r1.zip`,补充此前 `ksf-workshop-board-wecom-auto-20261103-r5.zip`。r5后端包不含本次Skill更新;Skill包不含Function或站点部署。两个包及通讯录运行配置需配套,由运维001按既有分工安装。目前仅完成本地代码和隔离验证,未部署、未取得真实员工姓名。
+
+## 改动与依赖
+
+安装目标为 `/opt/data/skills/ksf-workshop-runtime/`。在已核验的当前集成基线上,仅三份说明变化:`SKILL.md`、`references/identity.md`、`references/workshop-tasks.md`;`FILES.sha256`同步更新。包中五份运行脚本、`runtime-lock.json`和`references/write-tasks.md`沿用已核验版本。安装器也支持部分已知历史基线;未知内容立即停止,不修改指纹来放行。
+
+Skill不存放CorpSecret、access_token、botId/senderId原值或真实员工档案。现有 `credential-binding.json`、`/opt/data/ksf-workshop-state/`以及网关登记工具和维护人配置保留。Skill不会安装不存在的 `ksf_register_current_employee` 工具;工具本身须已接入当前消息上下文。
+
+通讯录权限、企业范围、aibot发送者到通讯录UserID的已核实映射仍按 [r5服务端说明](wecom-identity-integration-20261103.md) 配置。不能用sender前缀或聊天自报姓名替代映射,密钥只由维护人在包外私有配置中管理。
+
+## 安装
+
+将包解到独立目录,用现有Skill属主(通常hermes)执行,Node.js 20或以上。先在包根核验:
+
+```sh
+sha256sum -c FILES.sha256
+node scripts/install-workshop-skill.mjs --check
+```
+
+`--check`只检查源文件、目标已知基线、绑定文件和状态目录,不改文件、不连接网络。当前基线预计 `filesChecked=11`、`changed`恰为上述三份说明及清单;若为已安装版本则changed为空。未知基线、软链接或包哈希不符时停止,交维护人核对。
+
+预检通过后执行一次:
+
+```sh
+node scripts/install-workshop-skill.mjs
+```
+
+安装器返回备份绝对路径及变更文件名,安装SKILL.md前先替换引用文件,随后逐文件回读。保存stdout/stderr/exitcode。凭据和状态目录校验应保持true;重复安装changed为空,不生成新备份。安装不改网关配置,不执行任何业务上传。最后在目标Skill目录核对:
+
+```sh
+cd /opt/data/skills/ksf-workshop-runtime
+sha256sum -c FILES.sha256
+```
+
+按现有机制刷新Skill加载;安装输出 `discoveryVerified=false`,不能据此宣称网关已加载或实名已接通。r5后端部署与私有配置完成后,再从企微真实新消息验收。
+
+## 验收
+
+1. 两位员工分别通过数字人提交真实新消息;当前消息登记工具返回各自员工键。群聊不能共用一个员工session,既有同一员工档案继续复用。
+2. 用原有start/save入口及稳定业务键操作。加密wrapper返回中检查 `entryResult.result.employeeIdentity.status=verified`、`automatic=true`,以及 `employee.nameVerified=true`、`source=wecom_directory`、非空姓名和核验时间;姓名须与当前发送者的通讯录记录一致。
+3. 按保存返回的artifactId回读并查看看板姓名归属。同一员工跨工作坊应归并,原报告ID、正文及版本不变;不同员工不因同名合并。登记工具仍返回本地needsNameBinding时,不直接改本地档案或重存报告来消除标签。
+4. 缺配置时,如业务保存成功而实名为pending,分别记录“成果已保存”和“实名待同步”;旧服务缺少实名回执时也不重存。接口报错/结果未知则保留原键核查,不自动重试或换键。真实页面打开后才记录browserVerified。
+
+通讯录姓名核验、正式员工确认和企业批准是独立状态。Skill安装成功、夹具测试成功均不构成生产实名验收。
+
+## 回退
+
+使用安装回执中的备份目录,核对目标仍为本包清单版本后恢复changed列出的文件及原清单;遇到他人新修改先停止。备份位于目标Skill之外。凭据绑定、员工档案和业务记录未迁移,不重跑工作坊。后端和看板有各自的r5回退流程。

+ 41 - 0
docs/workshop-skill-validation-20261103.json

@@ -0,0 +1,41 @@
+{
+  "release": "ksf.workshop-skill.employee.20261103.r1",
+  "validatedAt": "2026-11-03T12:06:33.054Z",
+  "skillValidation": {
+    "valid": true,
+    "validator": "skill-creator/scripts/quick_validate.py",
+    "encoding": "UTF-8"
+  },
+  "workspaceTests": {
+    "passed": 102,
+    "failed": 0,
+    "command": "node --test --test-reporter=spec test/*.test.js"
+  },
+  "staticChecks": {
+    "passed": 13,
+    "failed": 0,
+    "command": "node scripts/check.mjs"
+  },
+  "baselineUpgrade": {
+    "ok": true,
+    "baselineUpgradeVerified": true,
+    "readOnlyPlanVerified": true,
+    "changed": [
+      "references/workshop-tasks.md",
+      "references/identity.md",
+      "SKILL.md",
+      "FILES.sha256"
+    ],
+    "filesVerified": 11,
+    "backupVerified": true,
+    "repeatInstallChanges": 0,
+    "credentialBindingPreserved": true,
+    "stateDirectoryPreserved": true,
+    "networkRequests": 0,
+    "productionBusinessWrites": 0
+  },
+  "runtimeScriptChanges": 0,
+  "realWecomDirectoryVerified": false,
+  "browserVerified": false,
+  "deployed": false
+}

+ 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'}));

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

@@ -0,0 +1,106 @@
+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"};
+// Checked local integration baseline; never accept arbitrary target edits.
+const expectedUploadBaseline={
+ 'SKILL.md':'1d4f41f8541b5f033ece5362e1021e35826217296c68240bd659a05b8ea43df2',
+ 'references/identity.md':'8495c6d664fc5d445ab10af90484e39aead98ca2c8bc73e592db46ba277d4449',
+ 'references/workshop-tasks.md':'3db07b2d66b6c6338e50dbd01995b6db2eb9a781646e73748f7cc12351cce6b7',
+ 'references/write-tasks.md':'197a02db39a78bd14803c27b455a51681b6ec32f8d50e69d6e32486a2e9fe2cc',
+ 'scripts/ksf-workshop.mjs':'95d05ac21310b7ccdd204cd5d01b4dcb7fa6944694acda089f1ff32eacda4fc7',
+ 'scripts/ksf-secure.mjs':'1fe19bf41e3b57aa78061a15d04d346b3a4c189452d15e98434c35a727bb81c1',
+ 'scripts/ksf-identity.mjs':'9224ef89a96425890d54961fda1f4fcae3403aa7c753fe1d70ac62082f2ff77e',
+ 'scripts/ksf-identity-bridge.mjs':'6ba3f0cb6527bc4a0a6218a898472ac65aa3dd9f3f57b597d77efcee641a7540',
+ 'scripts/ksf.mjs':'7ef128f7d022bfbc15c0ec813bfd87995c6297c6efe407a563c840f014aba477',
+ 'runtime-lock.json':'2605cbe8fd7be43b5969a4991f978dc0768dee5b4d65d18f7af15f75e9004a79'
+};
+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,{apply=true}={}){
+ 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],expectedUploadBaseline[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 manifest={name:'FILES.sha256',origin:path.join(source,'FILES.sha256'),target:path.join(destination,'FILES.sha256')};
+ plain(manifest.origin);manifest.hash=hash(fs.readFileSync(manifest.origin));
+ if(fs.existsSync(manifest.target)){
+  plain(manifest.target);const before=fs.readFileSync(manifest.target),current=hash(before);
+  if(current!==manifest.hash){if(current!=='6d0ab7383ef7556c820bcb322fd8e853273273b948f79e64113dc6050e6b2055')fail('TARGET_MANIFEST_HAS_UNEXPECTED_CONTENT');changes.push({...manifest,before});}
+ }else changes.push({...manifest,before:null});
+ entries.push(manifest);
+ if(!apply)return{ok:true,readOnly:true,changed:changes.map(x=>x.name),destination,filesChecked:entries.length,credentialBindingPreserved:true,stateDirectoryPreserved:true};
+ 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});}
+   if(e.before?hash(fs.readFileSync(e.target))!==hash(e.before):fs.existsSync(e.target))fail('TARGET_CHANGED_DURING_INSTALL');
+   const temp=e.target+'.update-'+crypto.randomUUID();
+   try{fs.copyFileSync(e.origin,temp,fs.constants.COPYFILE_EXCL);if(e.before)fs.chmodSync(temp,fs.statSync(e.target).mode&0o777);fs.renameSync(temp,e.target);done.push(e);}finally{if(fs.existsSync(temp))fs.unlinkSync(temp);}
+  }
+  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,readOnly:false,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)||(process.argv.length===3&&process.argv[2]==='--check')))fail('LINUX_CHECK_OR_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',{apply:process.argv[2]!=='--check'}),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;}
+}

+ 36 - 0
scripts/package-workshop-skill.mjs

@@ -0,0 +1,36 @@
+import fs from 'node:fs';
+import path from 'node:path';
+import crypto from 'node:crypto';
+import { fileURLToPath } from 'node:url';
+
+const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
+const skill = 'skills/ksf-workshop-runtime';
+const sha = bytes => crypto.createHash('sha256').update(bytes).digest('hex');
+const [flag, directory, ...extra] = process.argv.slice(2);
+if (flag !== '--out' || !directory || !path.isAbsolute(directory) || extra.length || fs.existsSync(directory)) {
+  throw new Error('Use --out ABSOLUTE_NEW_DIRECTORY');
+}
+const entries = fs.readFileSync(path.join(root, skill, 'FILES.sha256'), 'utf8').trim().split(/\r?\n/).map(line => {
+  const match = line.match(/^([a-f0-9]{64})  ([A-Za-z0-9_./-]+)$/);
+  if (!match || match[2].split('/').includes('..')) throw new Error('Invalid Skill manifest');
+  if (sha(fs.readFileSync(path.join(root, skill, match[2]))) !== match[1]) throw new Error('Skill hash mismatch');
+  return skill + '/' + match[2];
+});
+if (new Set(entries).size !== 10) throw new Error('Expected ten Skill manifest entries');
+const files = [...entries, skill + '/FILES.sha256',
+  'scripts/install-workshop-skill.mjs', 'scripts/build-workshop-skill.mjs', 'scripts/package-workshop-skill.mjs',
+  'docs/workshop-skill-employee-identity-20261103.md', 'docs/workshop-skill-validation-20261103.json',
+  'docs/wecom-identity-integration-20261103.md',
+].sort();
+for (const file of files) {
+  const stat = fs.lstatSync(path.join(root, file));
+  if (!stat.isFile() || stat.isSymbolicLink() || /credential-binding|participants\/|agents\//.test(file)) throw new Error('Unexpected package file');
+}
+fs.mkdirSync(directory, { recursive: true });
+for (const file of files) {
+  const target = path.join(directory, file);
+  fs.mkdirSync(path.dirname(target), { recursive: true });
+  fs.copyFileSync(path.join(root, file), target, fs.constants.COPYFILE_EXCL);
+}
+fs.writeFileSync(path.join(directory, 'FILES.sha256'), files.map(file => sha(fs.readFileSync(path.join(directory, file))) + '  ' + file).join('\n') + '\n', { flag: 'wx' });
+console.log(JSON.stringify({ ok: true, release: 'ksf.workshop-skill.employee.20261103.r1', directory, files: files.length + 1, productionWrites: 0, deployed: false }));

+ 10 - 0
skills/ksf-workshop-runtime/FILES.sha256

@@ -0,0 +1,10 @@
+032a2502cd7a74a33f800638fd2a3c3568233b7118f5a9adbfea0446ae33f30d  references/workshop-tasks.md
+197a02db39a78bd14803c27b455a51681b6ec32f8d50e69d6e32486a2e9fe2cc  references/write-tasks.md
+1fe19bf41e3b57aa78061a15d04d346b3a4c189452d15e98434c35a727bb81c1  scripts/ksf-secure.mjs
+2605cbe8fd7be43b5969a4991f978dc0768dee5b4d65d18f7af15f75e9004a79  runtime-lock.json
+619568567035417f018f25b73ceb32f704274a3c5668d3b591215b41acce5ae3  references/identity.md
+6ba3f0cb6527bc4a0a6218a898472ac65aa3dd9f3f57b597d77efcee641a7540  scripts/ksf-identity-bridge.mjs
+7ef128f7d022bfbc15c0ec813bfd87995c6297c6efe407a563c840f014aba477  scripts/ksf.mjs
+9224ef89a96425890d54961fda1f4fcae3403aa7c753fe1d70ac62082f2ff77e  scripts/ksf-identity.mjs
+95d05ac21310b7ccdd204cd5d01b4dcb7fa6944694acda089f1ff32eacda4fc7  scripts/ksf-workshop.mjs
+b3161c29cf0c7b6c8a6ce1f0308d99c60acab801ca7affc52d39c4ea1c61e27e  SKILL.md

+ 87 - 0
skills/ksf-workshop-runtime/SKILL.md

@@ -0,0 +1,87 @@
+---
+name: ksf-workshop-runtime
+description: 康师傅面类产品创新工作坊。从企业微信数字人当前消息登记员工,上传时核对通讯录实名与成果归属;检索及查重资料,保存阶段报告、机会卡、项目和新品方案并更新公开看板;也支持原文导入和正式报告确认流程。
+---
+
+# 康师傅产品创新工作坊
+
+围绕 S1 品类市场分析 → S2 用户需求与痛点 → S3 机会识别 → S4 验证评估 → S5 场景与卖点设计带练。每阶段先检索已有材料,处理用户提供的素材,产出报告和对应结构化数据,保存后用服务返回编号回读。重点拌面,研究范围限面类。
+
+看板公开查看,不要求员工登录。右上角“我是”只是筛选;服务凭据授权写入,不代表员工本人完成正式确认。
+
+## 执行入口
+
+```sh
+node /opt/data/skills/ksf-workshop-runtime/scripts/ksf-secure.mjs <命令> --agent-profile <当前Agent完整档案键> --input /绝对路径/任务.json --request-id <本次操作稳定标记>
+```
+
+| 任务 | 命令 |
+|---|---|
+| 绑定实际员工、当前 Agent、小组和对话 | `workshop-start` |
+| 保存阶段报告、原素材关联、待验证项;可同时生成机会卡 | `workshop-save` |
+| 选择已有机会卡建立课堂项目 | `project-create` |
+| 记录阶段推进、卡住或课堂完成 | `project-progress` |
+| 保存新品方案版本,关联规格、配料及生产路线 | `concept-save` |
+| 回读项目、阶段成果、方案和事件 | `project-status` |
+| 将已取得的品类/竞品/原料/工艺/证据等资料按合同字段入库 | `workshop-import` |
+| 对实际采集原文和结构化记录做只读精确查重 | `workshop-dedup` |
+| 检索已有资料、产品、证据和报告 | `workshop-search` |
+| 按真实编号精确回读会话、报告等记录 | `workshop-read` |
+| 只读核对服务合同及身份字段支持 | `workshop-contract` |
+
+输入格式与字段映射见 [references/workshop-tasks.md](references/workshop-tasks.md)。准备任务前按相关章节读取;报告使用看板既有章节结构,无需生成或执行 HTML 来显示课堂成果。
+
+既有 `status` / `demo` / `connection` 不接受 input;`ingest` / `report-preview` / `report-submit` 保留原路径,格式见 [references/write-tasks.md](references/write-tasks.md)。原文采集本身使用已有采集工具,不把采集计划当结果。
+
+## 员工与 Agent 归属
+
+每个新数字人、新员工都先登记独立身份档案。数字人从自身可信运行配置取得身份,消息上下文补充渠道信息;只提取白名单字段,用 ksf-identity.mjs register 登记,已有档案用 check 核对。首次读取方式按实际运行环境核实,不能声称已接入不存在的自动身份 API。任何名称的数字人都可登记,不固定王刚001/运维001两个槽。业务写命令传当前执行者的完整档案键,role=workshop 才能执行课堂写入;角色不代表平台授权。详见 [references/identity.md](references/identity.md)。不能将聊天中提及的另一个机器人登记为自己。
+
+员工信息从本条消息可信发送者上下文取得,workshop-start 只引用员工 profileKey。企微 aibot 有 from.userid 时,调用无参数进程内工具 ksf_register_current_employee,工具直接读取本条消息的可信上下文并登记;工具成功后仅复用返回的 profile.key 与 agentProfileKey。旧 register-channel-employee --context 已关闭,不复制身份 JSON 到终端。自动登记按 botId+发送者标识区分员工,通讯录实名由上传服务查询,无需员工填写账号。通道标识不等于通讯录账号,企业ID未知仍为null,不拿群chatid替代发送者。只有没有可信稳定标识时才确认显示名,以 participant_registration 登记 UUID;人工显示名不算企微实名。手工同名候选须明确复用或新增,不能按名字合并。`participant.userId` 仅指已有 `_User.objectId`;缺档案停止创建会话。取不到身份不等于扫描环境变量或凭据。
+
+### 数字人上传时的员工识别流程
+
+1. 每次处理企微员工的实质成果并准备上传时,先通过 `ksf_register_current_employee` 取得本条消息的员工键和执行 Agent 键,核对本地会话绑定。同一人跨消息复用登记键;换发送者须切换到对应员工的 session,群 ID 不能用作员工键。工具缺失或失败时停止本次自动识别流程,不把聊天中自报的姓名当作可信身份。
+2. 使用该员工档案执行 `workshop-start`,已有正确归属的 session 直接用于 `workshop-save`。这两个命令的服务端扩展负责查询企微通讯录并关联姓名、部门等信息;Skill 不接收通讯录密钥,也不在报告输入里添加猜测的 employee/name 字段。通讯录权限及 aibot 发送者映射由维护人配置。
+3. 核对成功回执中的 `employee` 与 `employeeIdentity`;加密入口的路径为 `entryResult.result`。只有 `status=verified` 且 `employee.nameVerified=true`、`source=wecom_directory`、姓名非空时,才能说明已取得企微通讯录实名。`pending` 或旧服务无这两个字段时,分别说明“发送者已登记,实名待同步”或“服务尚未返回实名核验结果”,不说已经实名接入。
+4. 报告保存成功后按原编号回读,保留业务键与身份核对结果。实名待同步不等于保存失败,不重存、不换键、不重建员工或会话来消除标签;通讯录查询报错则保留原键按实际回执核查。看板显示验收另按返回的成果链接完成,不把 API 成功说成浏览器验收通过。
+
+具体回执、缺权限处理及本地待绑定标签与看板姓名的区别见 [references/identity.md](references/identity.md#上传时查询企业微信通讯录实名)。
+
+身份来源与业务 dataMode 独立:人工登记身份不要求 manual。构造待查清单的 session 从 start 就填 sample;workshop-save 继承 session 的 dataMode,不接收该字段。账号标识来自登记/配置仍是未认证资料,不代表平台认证成功。
+
+首次 start 返回 `sessionId` 与 `participantKey`。将本地会话状态(工作坊、真实上下文键、sessionId、projectId、已提交任务键/版本)保存在 `/opt/data/ksf-workshop-state/`,不写凭据。换员工/小组/对话时分别登记;同名不等于同一人。跨 Agent 继续项目时使用同一参与者/小组归属并新建当前 Agent 的 session。
+
+保存后用 `workshop-read` 按服务返回的 className/objectId 精确回读。workshop-search 不支持 sessionId 过滤;尚未创建项目时不要调用 project-status。读取输出会移除私有账号、会话键及服务 ownerId;公开看板仅显示必要归属。真实账号标识不要放进报告正文或原文。
+
+## 阶段工作方式
+
+- S1:分类与竞品;品类、产品、SKU、观察价分开存。找不到资料则保存待查问题,不能生成虚构市场数字。
+- S2:原文 → 证据 → 人群/场景/JTBD/痛点。引文逐字绑定已保存来源;KANO 分档建议与真实问卷结果分开。
+- S3:赛道×用户×场景形成待验证机会卡;用户选择后执行 project-create,不能仅把卡状态改成“已立项”就说项目已建。
+- S4:在项目下提交验证报告,记录模型、得分依据、工艺/成本/合规限制和缺项。没有原材料/产线依据时不说量产可行。
+- S5:保存方案版本,规格、配料、工艺路线、目标人群、场景、买点及未决问题相互对应;再保存课堂报告。完成当前阶段成果后才推进,项目“完成”指课堂任务完成,不等于企业批准立项或已具备量产条件。
+
+用户已经要求开展工作坊实操并上传结果时,每个有实质产出的任务结束就保存,卡住时也记录进度,不反复索要上传许可。闲聊和无关消息不上传;不把完整私人聊天直接公开。用户只要求审阅且明确不保存时,不执行写入。
+
+## 数据与确认边界
+
+课堂成果写为 `dashboardState: workshop`,页面明确显示“课堂过程成果/未经正式确认”。构造素材始终 `dataMode: sample`,标题带“样例”,不能改标签当真实调研。正式报告仍走 report-preview → 真人员工确认 → report-submit;员工通道未开通就停在预览,不代确认、不造确认事件。
+
+资料入库与报告可读是两项交付。先通过 workshop-import 保存所需产品/原料/工艺/证据实体,取得真实编号,再关联进报告或方案。来源缺失、参数未知、成本未核实都写进 pendingQuestions,不用常识填成事实。
+
+每个实际操作分配稳定 requestId,原样重试不会重复新增;改内容用新版本,不自动换业务键绕过冲突。导入批次可能已部分成功,失败后保留已返回 sourceId/jobId/objectId,回读再决定下一步。
+
+## 采集资料查重
+
+每次采集入库先查重。用实际原文和records调用只读workshop-dedup;workshop-import也自动执行该预检,服务端提交时在事务锁内再查一次。先用workshop-contract确认deduplication.schema=ksf.ingest-dedup.v1,缺能力停止导入,不把旧版重试幂等当跨批查重。独立查重不需要Agent写入档案参数。输入与完整规则见[references/workshop-tasks.md](references/workshop-tasks.md)末尾“采集资料查重”。
+
+完全重复复用原sourceId/objectId,按真实回执报created/unchanged;同源同内容换批次或业务键也不重复存实体。不同账号授权范围、sample/live、来源URL与平台不混合。上游稳定对象ID相同且内容完整一致的原始记录,可跨采集批次复用;没有稳定ID时不能把不同人的相同短评合成一个人。价格、观察时间、规格、正文或原始载荷变化保留为新快照/版本,不覆盖历史;同业务键内容冲突则保留原键核查。仅名称或语义相似先列候选,不自动合并。已有重复数据不删除,正式报告/机会/方案仍遵循各自版本和确认规则。
+
+资料实体可复用,采集执行、操作与导入回执仍留审计记录,数据库会随真实新增资料与审计正常增长。不要向用户承诺“数据库不增长”或“历史重复已清空”。
+
+加密入口逐命令在内存注入凭据,不改网关或重启。凭据过期、指纹不符、身份归属冲突、服务错误立即停并报告;不输出凭据、私钥或原始异常,不自行修改指纹放行。冻结 r7 不变。
+
+## 汇报
+
+根据实际 JSON 保留两层 invocationId、requestId、业务对象编号与阶段。课堂可见、正式确认、正式发布分别报告。可给公开链接 `/report/<artifactId>`、`/pool/<opportunityId>`、`/members`、`/stages`;命令未打开页面时,不声称本轮浏览器已验证。

+ 131 - 0
skills/ksf-workshop-runtime/references/identity.md

@@ -0,0 +1,131 @@
+# 员工与数字人身份登记
+
+每个新员工、每个新数字人先登记自己的档案,再创建工作坊会话。目录支持任意人数,不按姓名或两位现有机器人的角色分配固定槽。登记资料与平台认证分开:本流程不创建登录账号,`accountAuthenticated` 始终为 false。
+
+## 取得真实身份字段
+
+数字人优先读取当前执行实例的可信运行配置,渠道从当前消息上下文补充。先核实本环境实际提供的配置位置或接口,只提取下面的白名单,不扫描全盘凭据或输出配置原文。不能把聊天里提到的另一位机器人登记为自己,也不能声称已调用不存在的自动身份接口。
+
+Agent 字段:name、role、runtimePlatform、channel、identityBasis;account 内仅 platform、tenantId、accountId、accountName、runtimeAgentId、wecomAppAgentId;可附 collectedAt(UTC ISO 时间)。role 描述用途,课堂执行者用 workshop,运维用 operations,其他数字人可登记其他角色;目前只有 workshop 可用课堂写命令,角色不是授权凭据。王刚001、运维001是现有实例名,不是新数字人的默认名。
+
+员工字段:name、identityBasis、account;account 内为 platform、tenantId、accountId、accountName、employeeNumber;aibot 通道额外使用 identifierType、scopeId。可选 userId 只代表本应用已存在的 `_User.objectId`。通讯录成员 userId 填 account.accountId,corpId 填 tenantId,工号填 employeeNumber。aibot 的 from.userid 另按下节标注类型和作用域,不假定它等于通讯录成员 UserID。群聊会话键不能代替发送者 ID,运行时 Agent ID 与企微应用 agentId 也不能混用。
+
+未知账号字段填 null。密码、令牌、私钥、cookie 不属于身份资料。将身份摘要保存到 /opt/data 下的私有文件(0600),不包含配置中的其他字段。
+
+## 先登记数字人
+
+人工确认的最小摘要如下,替换成当前数字人的实际名称;取得真实字段时才补入。
+
+```json
+{
+  "name":"当前数字人的实际名称",
+  "role":"workshop",
+  "identityBasis":"operator_registration",
+  "runtimePlatform":"hermes",
+  "channel":"wecom",
+  "account":{"platform":"wecom","tenantId":null,"accountId":null,"accountName":null,"runtimeAgentId":null,"wecomAppAgentId":null}
+}
+```
+
+```sh
+node /opt/data/skills/ksf-workshop-runtime/scripts/ksf-identity.mjs status
+node /opt/data/skills/ksf-workshop-runtime/scripts/ksf-identity.mjs register --input /绝对路径/自身摘要.json
+node /opt/data/skills/ksf-workshop-runtime/scripts/ksf-identity.mjs check --agent-profile <返回的完整档案键> --input /绝对路径/自身摘要.json
+```
+
+`runtime_config` / `channel_context` 必须有可信稳定账号 ID;不得只凭名字使用这两种来源。无稳定 ID 时用 operator_registration,生成 agent-registration:UUID;有真实稳定 ID 时生成 agent-account:摘要。check 不因新 collectedAt 改变身份键,也不改旧档案。
+
+同名同角色但无稳定 ID 时返回 needsReview=true 和 candidates,尚未登记成功。确认是原实例后,在摘要中明确填 reuseKey;确认是另一实例则填 confirmNew:true。两者不可并用。不静默覆盖、不删除旧档案。已有明确档案键时直接 check,不通过就停。
+
+档案存于 /opt/data/ksf-workshop-state/agents/<键后缀>.json。原双槽版或更早的 agent-self.json 可用 adopt-agent --input /旧档案绝对路径 迁入通用目录;仅复制经验证的同一身份,原文件保留。旧 --slot 和 --agent-profile workshop 不再使用。
+
+## 再登记员工
+
+### 已取得企微 aibot 发送者时自动登记
+
+当前实际数字人只有王刚001和运维001。2026-11-02运维核查报告指出:此接入为 aibot WebSocket,适配器已读取 body.from.userid;单聊缺 chatid 时以发送者 ID 作为会话键。此结论依据报告中的字段映射,不依据 wo/wr 前缀判断。相同前缀不能证明账号类型或跨机器人同一性。
+
+自动登记必须通过无参数的进程内工具 `ksf_register_current_employee`。工具直接调用当前回合的 `identity_bridge.current_identity()`,核对维护人配置的 Agent 档案键与 botId 指纹,然后通过私有管道调用 Skill 的档案代码。只返回员工摘要、profile.key、agentProfileKey、invocationId 与是否新增;不把 botId/senderId 放进报告正文。
+
+工具是否安装、是否能取得本条消息,均以当前环境的真实返回为准;历史登记成功不能代替本次身份核对。工具缺失或 ok=false 时停止,不换用人工登记绕过本次自动识别验收。`wecom_identity_current` 是身份诊断查询,不直接登记;把它的六字段由模型复制到文件不构成可信登记。
+
+旧 `register-channel-employee --context` 及该命令的所有参数形式均返回 TRUSTED_IDENTITY_TOOL_REQUIRED。通用 register-employee 也拒绝 channel_context/aibot 账号输入,不能改走它复述通道身份。内部 ksf-identity-bridge.mjs 只由网关登记 handler 调用,不是模型可用的身份导入命令;stdin 本身不是身份认证,任意同 UID 代码执行不在本流程隔离保证内。
+
+Agent 自身 account.accountId 必须是当前实例实际订阅 botId,channel/account.platform 均为 wecom,role=workshop。维护人需在受控配置中将真实 bot 账号对应到 Agent 名称和档案键,不能从包名、聊天自述或模板推导自身身份。
+
+工具每次从本条消息取 senderId,群聊不能使用群 ID 当员工;单聊原始 chatId 可为 null,有效会话键校验可以使用 chatId 或 senderId,但不得反过来从会话键猜员工。没有姓名时显示待绑定标签。
+
+自动登记 account 保存 platform=wecom、identifierType=wecom_aibot_sender、scopeId=真实botid、accountId=from.userid;tenantId、accountName、employeeNumber 未知则 null,userId=null。作用域是机器人实例,不冒称企业 corpId。稳定键只由机器人作用域和发送者构成,不包含聊天ID、消息ID、名字;同一发送者跨单聊/群聊复用,不同群成员分别登记,跨机器人先保留独立别名,不自动合并。
+
+无姓名时本地登记生成“员工待绑定·摘要”标签并返回 needsNameBinding=true;这只表示本地档案没有姓名,不是企微实名查询的结论。无需员工回答账号问题即可建立发送者归属。上传服务可按下节取得通讯录姓名,不能凭 `_User`、报告 ownerName、历史 Agent 展示名或姓名相同来猜测身份。
+
+该模式的 workshop-start 还须读到服务合同 channelIdentitySchema=ksf.wecom-aibot-sender.v1;缺少则写入前停。私人账号与作用域仅进私有快照;公开看板显示已核验姓名,未取得姓名时显示可识别的通道资料或待同步标签,不暴露原始 ID。本地自动登记成功不代表通讯录实名链路已上线,需新的真实消息验收。
+
+### 上传时查询企业微信通讯录实名
+
+Skill 负责顺序与结果核对:当前消息可信登记 → 核对员工/Agent 与 session 绑定 → start 或 save → 核对员工回执 → 成果回读。不要另造“让员工先登录网页/手填企微账号”的前置步骤。续传前仍调用当前消息登记工具;本地状态须同时绑定员工键和 Agent 键,不能因为同处一个群或沿用上一次 session 就跳过发送者核对。
+
+查询由 `ksfStartWorkshopSession` / `ksfSaveWorkshopTask` 服务端扩展执行。start 使用可信登记注入的账号,save 使用原 session 的私有归属快照。aibot 发送者须通过受控映射对应到通讯录 UserID,不能根据 wo/wr 前缀认定是通讯录账号。配置包含企微应用通讯录权限、企业范围和发送者映射,由维护人在私有运行配置中管理;Skill 不读取、不保存这些密钥,也不调用外部通讯录接口拼装姓名。
+
+成功命令的业务结果增加以下字段;通过 ksf-secure 执行时查看 `entryResult.result`,直接调用运行库时查看返回对象:
+
+```json
+{
+  "employee": {
+    "key": "employee:<服务返回的稳定摘要>",
+    "name": "通讯录返回姓名",
+    "departmentNames": ["通讯录返回部门"],
+    "position": null,
+    "source": "wecom_directory",
+    "nameVerified": true,
+    "verifiedAt": "服务返回的核验时间"
+  },
+  "employeeIdentity": {"automatic": true, "status": "verified"}
+}
+```
+
+上述为字段示意,不可原样作为员工资料上传。实名通过条件为 `status=verified`、`employee.nameVerified=true`、`employee.source=wecom_directory`、姓名非空;记录实际稳定键、姓名和核验时间即可。通讯录姓名核验不代表员工本人正式确认报告,也不代表企业审批。
+
+| 实际返回 | 处理 |
+|---|---|
+| 业务成功且实名通过 | 说明成果已保存、通讯录实名已取得,按返回编号回读成果 |
+| 业务成功,status=pending,reason=WECOM_DIRECTORY_NOT_CONFIGURED | 说明成果已保存、发送者归属已建立,实名待维护人配置;不重跑保存 |
+| 业务成功,但没有 employee/employeeIdentity 或字段不一致 | 保留成功回执,说明实名能力尚未核实;交维护人检查版本,不推断已实名、不重存 |
+| 查询权限不足、映射缺失或身份冲突报错 | 报告实际错误并停止;新版服务在业务保存前查询,但身份同步审计可能已写入,不声称零写入 |
+| 网络失败或结果未知 | 保留原 requestId 与输入,先查回状态;不自动重试、不换键 |
+
+本地工具的 needsNameBinding 与上传回执属于两层结果:本地标签可仍为“员工待绑定”,看板按服务端实名档案展示。不要直接修改本地档案、覆盖旧报告正文或再开 session 来使两者字符串相同。同一员工跨工作坊由稳定身份关联,原成果 ID 和版本保持不变;不同机器人作用域只在服务端有已核验映射时才可归到同一员工。
+
+交付代码、安装 Skill、配置权限、取得本次通讯录回执及生产看板显示是不同验收项。尚未部署或缺配置时如实说明,不能用夹具姓名、历史回执或本地测试替代真实员工验收。后端依赖及维护人配置见仓库 `docs/wecom-identity-integration-20261103.md`,不将私有运行配置复制进 Skill 包。
+
+### 无可信发送者时人工登记
+
+从当前消息可信发送者信息取得姓名和账号。无法取得时,只问缺少的显示名/身份依据;已确认的信息直接复用,不让员工反复填写四项。
+
+```json
+{
+  "name":"实际员工显示名",
+  "identityBasis":"participant_registration",
+  "account":{"platform":"wecom","tenantId":null,"accountId":null,"accountName":null,"employeeNumber":null}
+}
+```
+
+```sh
+node /opt/data/skills/ksf-workshop-runtime/scripts/ksf-identity.mjs register-employee --input /绝对路径/员工摘要.json
+node /opt/data/skills/ksf-workshop-runtime/scripts/ksf-identity.mjs lookup-employee --name 实际员工显示名
+node /opt/data/skills/ksf-workshop-runtime/scripts/ksf-identity.mjs check-employee --participant-profile <员工完整档案键> --input /绝对路径/员工摘要.json
+```
+
+channel_context 必须有稳定发送者账号(企微账号需 tenantId+accountId)或其他可校验稳定 ID;existing_record 必须有已有账号依据。无稳定 ID 时 participant_registration 生成 participant-registration:UUID,并在后续消息复用该键。稳定账号生成 participant-account:摘要;同账号同资料返回原键,不新增。
+
+同名员工即使摘要内容相同也不能自动合并。needsReview=true 时先确认候选是同一人:明确复用才在摘要填 reuseKey;确认不同人填 confirmNew:true 新建。身份字段冲突则停,不通过换键绕过。姓名检索只用于找候选,不能证明身份。
+
+员工档案位于 /opt/data/ksf-workshop-state/participants/<键后缀>.json。跨容器无法自动发现对方的本地 UUID;需受控核对和迁移同一档案后复用,不能按姓名推算同一 UUID。登记只写本地档案,数据库归属快照在 workshop-start 成功时才落库。
+
+## 档案绑定到会话
+
+workshop-start 输入使用 `participant:{"profileKey":"员工完整档案键"}`;Agent 用命令行 `--agent-profile <Agent完整档案键>`。入口加载两份档案并自动注入名称、键、身份来源和账号快照。缺档案、姓名冲突、角色不符均在创建会话前停止。写入前还检查服务合同 identitySchema 是否为 ksf.agent-self.v1;没有则等待两函数升级。
+
+本地状态记录真实上下文与这两个档案键的绑定,以及 workshopKey/sessionId/projectId/任务键版本,不保存凭据。换发送者或执行数字人需重新解析对应档案;多人群聊不能只用群会话键缓存一个员工。跨 Agent 延续项目需为当前执行者新建 session,再使用同一员工/小组的项目,不冒用另一位 Agent 的 session。
+
+人工登记身份与业务 dataMode 无关。构造待查清单从 start 就用 sample;save 继承 session。账号信息只进私有归属快照,不放报告正文。CLI 和公开看板均不返回完整账号;远端落库核验由受控管理端完成。本地登记成功不能表述为企微认证或远端员工账号开通。

+ 119 - 0
skills/ksf-workshop-runtime/references/workshop-tasks.md

@@ -0,0 +1,119 @@
+# 工作坊任务输入
+
+所有命令均由 ksf-secure.mjs 接收 `--input` 绝对路径及 `--request-id`。输入 JSON 不含凭据;同一操作重试保持 requestId 和原文件。当前 API 版本 `ksf.workshop.v1`。
+
+业务写命令必须同时传 `--agent-profile <当前Agent完整档案键>`,读取已登记的自身身份。新员工、新数字人先按 identity.md 登记,任何名称都适用;仅 role=workshop 可课堂写入。只读命令可不传档案键。
+
+## 1. workshop-start
+
+部署前可用 workshop-contract 输入 `{}` 只读读取合同;身份扩展标识应为 `workshop.identitySchema: ksf.agent-self.v1`。新版 start 会检查,不支持时在写入之前停止。本地身份登记不依赖该能力。
+
+企微 aibot 若已有接入层可信 from.userid,优先按 identity.md 自动登记并取得 profileKey,无需员工填写账号。该类型还须 channelIdentitySchema=ksf.wecom-aibot-sender.v1,执行 Agent 的实际botid须与员工账号scopeId一致。没有接入层逐消息上下文时不要制造文件冒充已接通。
+
+```json
+{
+  "workshopKey": "实际工作坊标识",
+  "participant": {"profileKey":"register-employee 返回的完整档案键"},
+  "conversation": {"key":"实际会话键","channel":"wecom"},
+  "groupId":"实际组号,没有则空字符串",
+  "dataMode":"manual"
+}
+```
+
+participant 不接收临时 key/account/userId;这些字段与 identityBasis 由员工档案注入。Agent 同样由所选自身档案注入。缺任一档案时创建前停止,账号格式和登记命令见 identity.md。员工登记中的 userId 仅能填写本应用已有 `_User.objectId`;企微 userId、工号另填 account。dataMode 描述材料:manual 为人工取得的实际资料,live 为实际在线采集,构造演示/待查清单为 sample;与身份如何登记无关。
+
+启用通讯录扩展后,start 成功返回 `sessionId`、`participantKey` 以及 `employee`、`employeeIdentity`。员工姓名来自服务端查询,不在 start 输入中新增 employee 字段;核对规则见 [identity.md](identity.md#上传时查询企业微信通讯录实名)。旧服务没有实名回执时,只能确认 session 已建,不能声明姓名已核验。
+
+## 2. workshop-save
+
+```json
+{
+  "sessionId":"start 返回值",
+  "projectId":"已有项目编号;尚未立项时省略",
+  "taskKey":"稳定任务键",
+  "version":1,
+  "stageCode":"S3",
+  "title":"阶段报告标题",
+  "artifactType":"opportunity_card",
+  "category":"拌面",
+  "rawText":"本次阶段成果完整正文,不是消费者原话的替代品",
+  "sourceIds":[],
+  "pendingQuestions":["尚缺的证据或条件"],
+  "report":{
+    "executive":["摘要,明确事实、假设和缺口"],
+    "chapters":[{"id":"c1","title":"机会分析","stage":"S3","sections":[{"kind":"para","title":"待验证问题","text":"实际内容"}]}]
+  },
+  "opportunity":{
+    "key":"稳定机会系列键","title":"机会标题","demandSummary":"需求描述",
+    "targetAudience":"待研究或已获证据的人群","usageScenario":"对应场景","fusionDefinition":"赛道×用户×场景,逐项说明依据与缺口"
+  }
+}
+```
+
+可用 artifactType:category_report / competitor_report / voc_report / pain_list / opportunity_card / assessment_report / concept。opportunity 可省略;存在时保存独立机会卡并关联报告。来源、报告、机会和进度在同一事务保存,冲突时回滚。报告输入不接受 HTML。
+
+不在 save 里填写 dataMode,继承 start 建立的 session。待查清单无真实研究材料时从 start 使用 sample,并将缺项逐项放入 pendingQuestions。
+
+report 采用现有看板结构:`executive` 数组,`chapters` 数组。小节支持 `para`/`longpara`(text)、`table`/`matrix`(data.cols、data.rows)、`findings`(data 为含 conclusion/attitude/evidence 的数组)、`two`、`milestones` 等现有类型。不要自造 `kind: text`。
+
+引文放 `report.evidence` 对象,例如 `e1:{sourceId:"已保存来源编号",claim:"待论证判断",quotes:[{text:"逐字原话",ref:"原始定位"}]}`。sourceId 必须同时在 sourceIds;服务逐字检查引文是否存在于来源 rawText,不匹配就拒绝。来源链接不能代替已取得正文。构造样例即便文字匹配,也不会被升级成真实证据。
+
+企微员工上传前用当前消息登记工具核对 session 所属员工,不能沿用同群其他人的 session。save 由服务端按 session 私有归属取得员工账号并查询实名,成功时附 `employee` 和 `employeeIdentity`;不要把员工账号、通讯录密钥写进 rawText/report,也不新增手工身份参数。保存成功但实名 pending/缺少回执时,保留 artifactId、原 requestId 并继续成果回读,单列实名待同步,不因姓名问题再次保存。服务报错或结果未知时保留原键核查。完整状态处理见 [identity.md](identity.md#上传时查询企业微信通讯录实名)。
+
+## 3. project-create / project-progress
+
+create:`{"sessionId":"…","opportunityId":"…","projectKey":"稳定项目键","title":"项目名称"}`。
+
+progress:`{"sessionId":"…","projectId":"…","toStage":"S4","state":"in_progress","note":"本次推进依据"}`。
+
+项目从 S3 建立,只允许当前阶段或推进一阶段;当前阶段没有项目成果不能前进。state:in_progress / blocked / completed,completed 仅用于 S5 且须有方案。创建的是课堂项目,不是企业正式审批。
+
+## 4. concept-save / project-status
+
+```json
+{
+  "sessionId":"…","projectId":"…","version":1,
+  "concept":{
+    "name":"拟定名称","targetAudience":"人群","usageScenario":"场景",
+    "specification":{"packaging":"已知包装或明确待验证","netWeightG":null},
+    "valueProposition":"用户买点","flavor":"风味方向",
+    "sellingPoints":["待验证卖点"],"productionSteps":[],"pendingQuestions":["企业工艺资料未提供"]
+  },
+  "formulaItems":[]
+}
+```
+
+配料有资料时每条为 `{materialId,component,amount,unit,basis,note}`;materialId 必须实际存在。amount 未知时省略,不能用 0 冒充。basis 明确每份、每批、每 100g 等计算基准。productionSteps 保留路线、processId 和约束依据;缺资料时空数组并说明缺口。方案版本连续递增。
+
+project-status:`{"sessionId":"…","projectId":"…"}`,回读项目、成果、事件、当前方案及配料。
+
+## 5. workshop-import / workshop-search
+
+先 search 当前合同支持的表,避免重建已有实体。search 输入沿用 ksfSearchRecords:`{className,keyword,filters,limit,cursor,noiseContext}`;raw/evidence 查询须指定真实研究范围与噪声规则,未判断资料选 pending/all,不能算作可用证据。
+
+filters 仅支持 reportType/dataMode/platform/projectId/recordState/category/stageCode/dashboardState,不支持 sessionId。精确回读改用 `workshop-read`:`{"className":"KfArtifact","objectId":"保存返回的 artifactId"}`;会话用 KfSession 和 sessionId。返回仍经服务鉴权,CLI 移除私有账号/会话键,不把内部身份资料复制进聊天。
+
+import 输入:`{source:{title,sourceKind,dataMode,rawText,...实际来源信息},records:[{itemKey,className,externalKey,data,dependsOn?}]}`。逐表字段以本次服务合同为准,先本地核对必填项、原文与单位。引用前面条目使用 `{"$item":"前条 itemKey"}`,并在 dependsOn 声明;服务返回真实编号后才转换 Pointer。sourceRef 由入口补入;data.dataMode 必须与 source.dataMode 一致。
+
+映射:
+
+| 内容 | 表 |
+|---|---|
+| 面类分类、分类关系 | KfCategory、KfCategoryRelation |
+| 竞品及具体规格 | KfProduct、KfSku、KfRetailPriceSnapshot |
+| 用户原文、引用和洞察 | 平台原始表、KfEvidence、KfEvidenceLink、KfInsight |
+| 原料、工艺路线、工序、执行标准 | KfMaterial、KfProcess、KfProcessStep、KfStandard |
+| 供应商、报价、成本依据 | KfSupplier、KfSupplyQuote |
+| 指标、验证模型与结果 | KfMarketMetric、KfAssessment |
+
+报告、机会卡、项目、方案和配料用各自工作坊命令,不用通用导入绕过归属和阶段规则。导入按依赖逐条预检、提交和回执核对;整批不是单一事务,失败时可能已落部分条目,保持业务键回读,不能自动清理或换键。
+
+## 采集资料查重
+
+workshop-dedup输入与workshop-import相同,包含source和records;只读,不登记来源、不建导入任务。source要求实际rawText、sourceKind和dataMode,不能只有URL。输出readOnly=true、schema=ksf.ingest-dedup.v1、source.operation以及items[].decision:reuse=精确命中;no-exact-match=当前未命中,仍须校验提交;deferred=来源尚未登记或依赖尚未解析,实际提交时再查。预检结果不是可跳过服务端校验的写入许可。
+
+来源按原文内容SHA-256、来源类型、dataMode、原样sourceUrl/platform及当前服务owner作用域查重;标题和采集时间不作为原文内容。源记录保留首次来源元数据,后续采集执行/回执保留批次。不同URL暂不自动视为同源,不删URL参数,文件在原件验证后走服务端查重。
+
+结构化资料比较完整已提交业务字段,不以名称作唯一键;有sourceRef的普通实体限定同一已复用来源,无sourceRef的分类/关系等按完整字段比较。原始平台记录有真实upstream ID时按表+platform+externalObjectId+dataMode查询,并比较完整载荷及业务内容;仅排除采集批次、索引、抓取时间和服务端审核/噪声状态,保留原始载荷中的数值与时间。无upstream ID只在同源、同索引、同载荷下复用。不同内容/报价/观察时间或SKU不合并。历史人工判断不被重采覆盖。超出扫描上限明确DEDUP_SCAN_LIMIT,不将未查完说成无重复。
+
+workshop-import返回sourceOperation、counts.created/unchanged及真实逐条operation/objectId,引用后续实体必须用实际回执编号,不能坚持使用本次计划新建编号。数据不同且业务键已有内容时仍IDEMPOTENCY_CONFLICT,按新事实设计版本,不自动换键重试。新采集资料与正常审计会增加,精确重复不重复创建实体,旧历史重复不在本命令清理范围。

+ 51 - 0
skills/ksf-workshop-runtime/references/write-tasks.md

@@ -0,0 +1,51 @@
+# 写入任务输入
+
+输入为 UTF-8 JSON 文件。文件只放业务数据,不放凭据、员工会话或主密钥。命令不接收任意云函数名,也不接收可注入的 client、callFn、confirmationEvent 或离线跳过确认的选项。
+
+## 已有采集结果入库:ingest
+
+根字段为 `requestKey`、`source`、`runKey`、`run`、`records`、`producer`。调用模块为培训包 `client/collection-bridge.mjs` 的 `ingestCollectedRecords`。
+
+- `requestKey` 和 `runKey`:沿用同一实际采集或导入批次的稳定键;不得为重试任意换键。
+- `source`:`title`、`sourceKind`、`dataMode`、`rawText` 必填。`rawText` 是取得的完整原文,来源指纹由程序重算。
+- `run`:必须明确提供 `dataMode`、`status`;另按已取得的实际采集事实提供平台、工具、采集时间与追踪号。不从 `collectionMode` 猜 `dataMode`。
+- `records`:每批 1–200 条;每条含 `itemKey`、`className`、`externalKey`、`data`。`data.dataMode` 必填。来源和执行的 Pointer 由程序取得真实编号后补入。
+- `producer`:提供实际 `skillName`、`skillVersion`。
+
+逐表字段、原始记录指纹、噪声范围参考冻结 r7 的:
+
+```text
+/opt/data/ksf-baseline/r7/unpacked/skill/references/raw-table-field-allowlists.json
+/opt/data/ksf-baseline/r7/unpacked/skill/vendor/shared-fingerprints.mjs
+```
+
+保留 `rawPayload`、`payloadHash`、`payloadLevel: voc_normalized`、`collectedAt`、`recordIndex` 和真实的 `idOrigin`。缺失的点赞数不填 0,没有原平台 ID 就标缺失,不编造 ID。`payloadHash` 与噪声内容指纹用途不同。噪声判定不能混在通用入库数据里。
+
+流程是来源登记 → 执行登记 → 同一份 items 预检 → 提交 → 取完回执并逐条核对。预检失败之前,来源和执行记录可能已经登记;不能笼统说“数据库零写入”。结果只输出经过核对的业务编号、计数和条目状态,不输出原文与凭据。
+
+采集入库前先按references/workshop-tasks.md的workshop-dedup只读查重,并确认服务合同deduplication.schema=ksf.ingest-dedup.v1;随后ingest仍走既有冻结入口。来源登记和实际提交由新版服务端再次精确查重,因此不能仅凭不同requestKey/externalKey判为新资料。复用真实编号,变化的数据保留新快照/版本;重复采集仍有审计日志,不承诺数据库完全不增长。
+
+## 报告:report-preview / report-submit
+
+JSON 根字段只接受:
+
+```json
+{
+  "reportDir": "/绝对路径/报告目录",
+  "seriesKey": "沿用实际报告系列键",
+  "version": 1,
+  "reportType": "integrated_banmian_rd_report",
+  "dataMode": "sample",
+  "stageCode": "S5"
+}
+```
+
+`reportDir` 中必须存在 `report.html`。所有码值先在本地校验;`workshop_auxiliary` 另需 `artifactSubtype`。`stageCode` 的 S1–S5 是当前客户端约束,不声称服务端实施相同枚举。
+
+`report-preview` 只建立预览,返回 `previewId` 与确认页 `confirmationUrl`,不代表确认、入库或发布成功。
+
+`report-submit` 需要在同一 JSON 中加入服务端返回的 `previewId`。版本与文件内容必须和预览时相同;确认人和事件从服务端读取。程序不接收用户自行拼的确认事件,也不代替员工点击。
+
+本轮培训样例固定 `sample`,只能展示或按明确要求建立预览;这个入口会在联网前阻止它执行 `report-submit`。其他数据模式按真实取得方式填写;不得为了发布修改模式。
+
+提交调用新版桥接层,桥接层复用冻结 r7 的报告流程。确认、上传、预检或回执任一失败即停。当前命令不提供“跳过确认”“沿用任意回执发布”或自动重试接口。失败时输出已经得到的 jobId、artifactId 和停在哪一步,先查状态再决定后续操作。

+ 50 - 0
skills/ksf-workshop-runtime/runtime-lock.json

@@ -0,0 +1,50 @@
+{
+  "version": "20261031-entry-1",
+  "runtime": {
+    "client/fmode-client.mjs": "13da6d12e848084a1a51343d15c5074e192ba22961832b06ff9b909941672acc",
+    "client/guarded-client.mjs": "7c29d96ab3bfc3f654d1311efa3483b6060655a0067b3aff960ea6294870eecd",
+    "client/collection-bridge.mjs": "d8dfb9a53b977d0698dc3cdafaa41ae3bb7dd0dea377df129d71107910664635",
+    "client/report-bridge.mjs": "83efe842a51529d80fc223b851df9e8810b4bfca085cae006c765f8dc7dd8f94",
+    "client/report-entry.mjs": "2673a2c1668174a9fd056ad049d61cdaac9e8380995a47236f61aa4474415bd5",
+    "client/contract-constants.mjs": "0d11694cda8a8c90af5eba792e51726b86a42b4d280172d7431d87ae7ed37ae0",
+    "client/stage-codes.mjs": "231357bd2163a6f2cd9cdf608b018a9cf017f0627a7d26e00c34aa646a3474df",
+    "training-demo/报告样例/report.html": "464957797fd3098644393afd3cf2edc3c9ba3e66ea1aaad9f7e6793f06f1e494",
+    "training-demo/报告样例/metadata.json": "ac62e7089d422e0901c6a58de1bea0cb39641f90dea3b8a251335de41aeb021a"
+  },
+  "r7": {
+    "CHANGELOG-r2.md": "5bc79d56dddab94c18caa4e092dbdf271421c4448caca0dc27495eab3a9bc531",
+    "CHANGELOG-r3.md": "1f34b246fb787a1d30f7da29cbf821964aeb71574a87f879cb6197f900e1d42d",
+    "CHANGELOG-r4.md": "a7192259aa3ee0e28cbdffa04b3b79fdd9b4b47113dd2eda296e689ed7bbed27",
+    "CHANGELOG-r5.md": "3e9b1aa993fb7502623659754b84cd2e89b5587af2bfaf3a7c17b08f70f6eadc",
+    "CHANGELOG-r6.md": "eec1508126486279638d761c05e150a80e5346b80241d4f3fbbfd79bd6d249cf",
+    "CHANGELOG-r7.md": "e84f4b495ed326b150c17ee41def211e25145162ac9f4e2f16baf2572f9e2b55",
+    "DELIVERY-v07.md": "13babfe4f89755cef67aafb6b55f2d177ef29d059636f69e0fbc2b743bfc6c6c",
+    "SKILL.md": "747c08995e6d10bdf0b4e97759c4a60a0a1bb3ad454eee6cecb21f8592d91919",
+    "references/auth-verification.md": "34fed7f3d7c15f8d72872ed6398156a41a857796460de4ad3fb891a792863ea0",
+    "references/cloud-contracts-v07-r1.json": "b9230ee2d1684951b67a8b8729a462d432004febdb3afaa45dc0c660d43eb6d1",
+    "references/cloud-contracts-v07-r1.md": "41f7dfa7d29aadafb1a438368994f78b65d99515e5d796121d232b78e3f4751d",
+    "references/cloud-contracts-v07.md": "fb4d9a11156b2e2b69fcbe90c10f210223cc4cef5ab1dca4aacb53d89a7a5452",
+    "references/codex-review-r1.md": "9fa019a5f237ff7575cdaa3c6b28a6f2ba05e36154e1ed541431dcbb8163cd37",
+    "references/entry-verify-codex.json": "fcd0349043cf020e999576c8179325b3cdd3871eb699fbf003490184863babb0",
+    "references/fingerprint-test-vectors.json": "f52845ce8d3fb7f13b6d7947e45bf608de24edb23ad7e8b467e5186c6926574c",
+    "references/noise-decisions-douyin-20.md": "27e662441ddc51caa6958df4aed3dc1284ffbbcc538166efd8975b6caec94335",
+    "references/noise-registry.json": "f4255742c88c2cfa90aabe6f28da6be01fcbf40fbdda7dc0bfef8e233cdbbed1",
+    "references/raw-table-field-allowlists.json": "0f8df98d2a57dfb4a6e5788629705ba7fbc831621fd3e4420f26e9629dfedc53",
+    "samples/douyin-trend-report.json": "c7a9a60f55aa77d0240e409e8d460179dfd27006ac5b3a06f78edf38a827ca8f",
+    "samples/noise-labels-douyin.json": "1c265533dc53ec03680fd36202effa17edb2c5abb24fabcc33932c2a56225c16",
+    "scripts/build-records.mjs": "2c59f15f85e2e3481dd9f877c0a1b2a0b2db53716a54ac3934c3c3a6945b223d",
+    "scripts/check-readiness.mjs": "403b15b37b30f3303908cc87871bf1a86bce73a4f79ac29a800d2eeb8b8ede51",
+    "scripts/ingest-pipeline.mjs": "5133d2c2abeea717b6ca54864846b054c396106a83de2f90eea729f2fe8af921",
+    "scripts/noise-assessment.mjs": "93973f81d38f028c9ef4ee56f85b0fb96be8dd54827428fbf175049163e18eab",
+    "scripts/noise-backfill.mjs": "df420690d7417dcc04aa6c76b8b1aa4aa8aeba3c9030c7687bf5eab7db0d8133",
+    "scripts/report-client.mjs": "b09e86390469c28a670c9bb3841ae7e8a4fcf30e1de430dc8fe85edc5d547dff",
+    "tests/behavior-submit.test.mjs": "a8da404fd080bd3f79b33f9d8a8b48355c89ddbcf704e2b7d2ad5913aa9694e1",
+    "tests/noise-backfill.test.mjs": "cb452eb3146c3a7a7bf09ba148841787247e49ece2c93404a71525d0738c48c2",
+    "tests/offline-acceptance.test.mjs": "03497f9605352d53ab35a54993e21e8e6f28524326d10196d1847b65e270f89a",
+    "tests/r1-fixes.test.mjs": "c8b1ddb5744d25748350181287cd328648c9aefe56a201df27bbc755a656c744",
+    "tests/report-client.test.mjs": "e66157315bfdc173b16de3997259b17efdcea53bb8ee3b3a1aa4e3a153b7eb80",
+    "tests/review-r2.test.mjs": "945fcfc9508410cde8d6944e79d457c7d0da26b69dee5f75870990513a3af52b",
+    "vendor/shared-fingerprints.mjs": "a9d30d1a175e1a22aeed4aae2e0532696efe41f3a9a4407020a295424d135dce",
+    "vendor/shared-fingerprints.test.mjs": "1dd3567200bc5165c5c7afad6876db85de4a0d7904876a9c3650d341617e1437"
+  }
+}

+ 35 - 0
skills/ksf-workshop-runtime/scripts/ksf-identity-bridge.mjs

@@ -0,0 +1,35 @@
+// Internal pipe transport for the in-process current-message registration tool.
+// Stdin is not authentication. Only the gateway handler validates provenance.
+// Same-UID arbitrary code execution is outside this integration's isolation scope.
+import fs from 'node:fs';
+import path from 'node:path';
+import crypto from 'node:crypto';
+import {fileURLToPath} from 'node:url';
+import {readAgentProfileByKey,registerChannelParticipant} from './ksf-identity.mjs';
+
+const fail=code=>{throw Object.assign(Error(code),{code});};
+export function registerBridgeIdentity(envelope,options={}){
+ if(!envelope||typeof envelope!=='object'||Array.isArray(envelope)||
+    Object.keys(envelope).sort().join(',')!=='agentProfileKey,identity,version'||
+    envelope.version!=='ksf.current-employee-registration.v1')fail('INVALID_BRIDGE_REQUEST');
+ const agentProfile=readAgentProfileByKey(envelope.agentProfileKey,options.profileDir);
+ if(agentProfile.role!=='workshop')fail('AGENT_ROLE_MISMATCH');
+ const result=registerChannelParticipant(envelope.identity,{...options,agentProfile});
+ return{ok:true,version:envelope.version,invocationId:crypto.randomUUID(),
+  changed:result.changed,profile:result.profile,agentProfileKey:agentProfile.key,
+  needsNameBinding:result.needsNameBinding,displayNameAuthenticated:false,
+  accountAuthenticated:false,networkRequests:0,databaseWrites:0};
+}
+
+if(process.argv[1]&&path.resolve(process.argv[1])===fileURLToPath(import.meta.url)){
+ try{
+  if(process.argv.length!==2)fail('BRIDGE_NO_ARGUMENTS_REQUIRED');
+  const buffer=Buffer.alloc(16385);let length=0;
+  while(length<buffer.length){const n=fs.readSync(0,buffer,length,buffer.length-length,null);if(!n)break;length+=n;}
+  if(length>16384)fail('BRIDGE_REQUEST_TOO_LARGE');
+  console.log(JSON.stringify(registerBridgeIdentity(JSON.parse(buffer.subarray(0,length).toString('utf8')))));
+ }catch(e){
+  const code=/^[A-Z][A-Z0-9_]+$/.test(e.code||'')?e.code:'IDENTITY_REGISTRATION_FAILED';
+  console.error(JSON.stringify({ok:false,error:code}));process.exitCode=1;
+ }
+}

+ 89 - 0
skills/ksf-workshop-runtime/scripts/ksf-identity.mjs

@@ -0,0 +1,89 @@
+import fs from 'node:fs';
+import path from 'node:path';
+import crypto from 'node:crypto';
+import {fileURLToPath} from 'node:url';
+
+const self=fileURLToPath(import.meta.url);
+export const STATE_DIR='/opt/data/ksf-workshop-state';
+export const PROFILE_DIR=path.join(STATE_DIR,'agents');
+export const PARTICIPANT_DIR=path.join(STATE_DIR,'participants');
+const fail=code=>{throw Object.assign(Error(code),{code});};
+const object=x=>x&&typeof x==='object'&&!Array.isArray(x);
+const canonical=x=>x&&typeof x==='object'?Array.isArray(x)?'['+x.map(canonical).join(',')+']':'{'+Object.keys(x).sort().map(k=>JSON.stringify(k)+':'+canonical(x[k])).join(',')+'}':JSON.stringify(x);
+const validDate=x=>typeof x==='string'&&/^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d(?:\.\d{1,3})?Z$/.test(x)&&Number.isFinite(Date.parse(x));
+function exact(x,keys){if(!object(x)||Object.keys(x).some(k=>!keys.includes(k)))fail('INVALID_IDENTITY_FIELDS');}
+function text(x){if(typeof x!=='string'||!x.trim()||x.length>200||/[\u0000-\u001f]/.test(x))fail('INVALID_IDENTITY_VALUE');return x.trim();}
+function safePath(file){if(typeof file!=='string'||!path.isAbsolute(file))fail('ABSOLUTE_PATH_REQUIRED');for(let p=path.resolve(file);;p=path.dirname(p)){if(fs.existsSync(p)&&fs.lstatSync(p).isSymbolicLink())fail('IDENTITY_SYMLINK_PATH');if(path.dirname(p)===p)break;}}
+function readJSON(file,code){safePath(file);if(!fs.existsSync(file))fail(code);const st=fs.lstatSync(file);if(!st.isFile())fail('INVALID_IDENTITY_FILE');if(process.platform==='linux'&&(st.uid!==process.geteuid()||(st.mode&0o777)!==0o600))fail('IDENTITY_FILE_PERMISSIONS');try{return JSON.parse(fs.readFileSync(file,'utf8'));}catch{fail('INVALID_IDENTITY_FILE');}}
+function writeJSON(directory,file,value){safePath(directory);safePath(file);fs.mkdirSync(directory,{recursive:true,mode:0o700});safePath(directory);const fd=fs.openSync(file,'wx',0o600);try{fs.writeFileSync(fd,JSON.stringify(value,null,2)+'\n');fs.fsyncSync(fd);}finally{fs.closeSync(fd);}}
+function account(value,fields){exact(value,fields);if(typeof value.platform!=='string'||!value.platform.trim())fail('INVALID_IDENTITY_VALUE');for(const[k,v]of Object.entries(value))if(v!==null&&(typeof v!=='string'||!v.trim()||v.length>200||/[\u0000-\u001f]/.test(v)))fail('INVALID_IDENTITY_VALUE');return Object.fromEntries(fields.map(k=>[k,k==='platform'?text(value[k]):value[k]==null?null:text(value[k])]));}
+const AGENT_ACCOUNT=['platform','tenantId','accountId','accountName','runtimeAgentId','wecomAppAgentId'];
+const PARTICIPANT_ACCOUNT=['platform','tenantId','accountId','accountName','employeeNumber'];
+export function normalizeAgentAccount(v){return account(v,AGENT_ACCOUNT);}
+export function normalizeParticipantAccount(v){const scoped=object(v)&&(Object.hasOwn(v,'identifierType')||Object.hasOwn(v,'scopeId'));const a=account(v,scoped?[...PARTICIPANT_ACCOUNT,'identifierType','scopeId']:PARTICIPANT_ACCOUNT);if(scoped&&(a.platform!=='wecom'||a.identifierType!=='wecom_aibot_sender'||!a.scopeId||!a.accountId))fail('INVALID_CHANNEL_ACCOUNT');return a;}
+function stableAgentKey(p){const a=p.account;if(!a.accountId&&!a.runtimeAgentId&&!(a.tenantId&&a.wecomAppAgentId))return null;const identity={runtimePlatform:p.runtimePlatform??null,platform:a.platform,tenantId:a.tenantId,accountId:a.accountId,runtimeAgentId:a.runtimeAgentId,wecomAppAgentId:a.wecomAppAgentId};return'agent-account:'+crypto.createHash('sha256').update(canonical(identity)).digest('hex');}
+function stableParticipantKey(p){const a=p.account,anchor=a.identifierType==='wecom_aibot_sender'?{kind:'wecom_aibot_sender',platform:'wecom',scopeId:a.scopeId,id:a.accountId}:p.userId?{kind:'application_user',appId:'FKieRbIoGt_main',id:p.userId}:a.accountId&&(a.platform!=='wecom'||a.tenantId)?{kind:'channel_account',platform:a.platform,tenantId:a.tenantId,id:a.accountId}:a.employeeNumber&&a.tenantId?{kind:'employee_number',platform:a.platform,tenantId:a.tenantId,id:a.employeeNumber}:null;return anchor?'participant-account:'+crypto.createHash('sha256').update(canonical(anchor)).digest('hex'):null;}
+const UUID='[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}';
+function agentFile(key,dir=PROFILE_DIR){if(typeof key!=='string'||!new RegExp('^agent-(?:registration:'+UUID+'|account:[a-f0-9]{64})$').test(key))fail('INVALID_AGENT_KEY');return path.join(dir,key.split(':')[1]+'.json');}
+function participantFile(key,dir=PARTICIPANT_DIR){if(typeof key!=='string'||!new RegExp('^participant-(?:registration:'+UUID+'|account:[a-f0-9]{64})$').test(key))fail('INVALID_PARTICIPANT_KEY');return path.join(dir,key.split(':')[1]+'.json');}
+function agentInput(input){exact(input,['name','role','identityBasis','runtimePlatform','channel','account','collectedAt','confirmNew','reuseKey']);const name=text(input.name),role=text(input.role);if(!/^[\p{L}\p{N}][\p{L}\p{N} _.-]{0,59}$/u.test(role)||!['operator_registration','runtime_config','channel_context'].includes(input.identityBasis))fail('INVALID_IDENTITY_VALUE');const normalized={name,role,identityBasis:input.identityBasis,runtimePlatform:input.runtimePlatform==null?null:text(input.runtimePlatform),channel:input.channel==null?null:text(input.channel),account:normalizeAgentAccount(input.account)};if(input.identityBasis!=='operator_registration'&&!stableAgentKey(normalized))fail('RUNTIME_AGENT_ID_REQUIRED');if(input.collectedAt!==undefined&&!validDate(input.collectedAt))fail('INVALID_IDENTITY_VALUE');if(input.confirmNew!==undefined&&input.confirmNew!==true)fail('INVALID_IDENTITY_VALUE');if(input.reuseKey!==undefined)agentFile(input.reuseKey);if(input.reuseKey&&input.confirmNew)fail('INVALID_IDENTITY_VALUE');return normalized;}
+function participantInput(input){exact(input,['name','identityBasis','account','userId','reuseKey','confirmNew']);const name=text(input.name);if(!['channel_context','participant_registration','existing_record'].includes(input.identityBasis))fail('INVALID_IDENTITY_VALUE');const normalized={name,identityBasis:input.identityBasis,account:normalizeParticipantAccount(input.account),userId:input.userId==null?null:text(input.userId)};if(normalized.account.identifierType==='wecom_aibot_sender'&&(normalized.identityBasis!=='channel_context'||normalized.userId))fail('INVALID_CHANNEL_ACCOUNT');if(input.identityBasis==='existing_record'&&!normalized.userId&&!stableParticipantKey(normalized))fail('STABLE_EMPLOYEE_ID_REQUIRED');if(input.identityBasis==='channel_context'&&!stableParticipantKey(normalized))fail('STABLE_EMPLOYEE_ID_REQUIRED');if(input.reuseKey!==undefined)participantFile(input.reuseKey);if(input.confirmNew!==undefined&&input.confirmNew!==true)fail('INVALID_IDENTITY_VALUE');if(input.reuseKey&&input.confirmNew)fail('INVALID_IDENTITY_VALUE');return normalized;}
+function profiles(dir,kind){safePath(dir);if(!fs.existsSync(dir))return[];const rows=[];for(const e of fs.readdirSync(dir,{withFileTypes:true})){if(!/^(?:[a-f0-9-]{36}|[a-f0-9]{64})\.json$/.test(e.name))continue;const file=path.join(dir,e.name);if(fs.lstatSync(file).isSymbolicLink())fail('IDENTITY_SYMLINK_PATH');if(!e.isFile())fail('INVALID_IDENTITY_FILE');const row=kind==='agent'?readAgentProfile(file):readParticipantProfileByPath(file);if(e.name!==row.key.split(':')[1]+'.json')fail('INVALID_IDENTITY_FILE');rows.push(row);}return rows;}
+function agentSummary(p){return p?{key:p.key,name:p.name,role:p.role,identityBasis:p.identityBasis,runtimePlatform:p.runtimePlatform,channel:p.channel,platform:p.account.platform,configuredAccountFields:Object.keys(p.account).filter(k=>k!=='platform'&&p.account[k]!==null),collectedAt:p.collectedAt,registeredAt:p.registeredAt}:null;}
+function participantSummary(p){return p?{key:p.key,name:p.name,identityBasis:p.identityBasis,platform:p.account.platform,configuredAccountFields:Object.keys(p.account).filter(k=>k!=='platform'&&p.account[k]!==null),hasApplicationUserRef:Boolean(p.userId),registeredAt:p.registeredAt}:null;}
+function savedAgentFields(p){return{name:p.name,role:p.role,identityBasis:p.identityBasis,runtimePlatform:p.runtimePlatform,channel:p.channel,account:p.account};}
+function savedParticipantFields(p){return{name:p.name,identityBasis:p.identityBasis,account:p.account,userId:p.userId};}
+export function readAgentProfile(file){const p=readJSON(file,'AGENT_PROFILE_REQUIRED');exact(p,['version','key','name','role','identityBasis','runtimePlatform','channel','account','collectedAt','registeredAt']);const collectedAt=p.collectedAt??p.registeredAt;if(p.version!=='ksf.agent-self.v1'||typeof p.key!=='string'||!validDate(p.registeredAt)||!validDate(collectedAt))fail('INVALID_IDENTITY_FILE');const fields=agentInput({...savedAgentFields(p),collectedAt}),validKey=fields.identityBasis==='operator_registration'&&new RegExp('^agent-registration:'+UUID+'$').test(p.key)||p.key===stableAgentKey(fields);if(!validKey)fail('INVALID_IDENTITY_FILE');return{...p,...fields,collectedAt};}
+export function readAgentProfileByKey(key,dir=PROFILE_DIR){const p=readAgentProfile(agentFile(key,dir));if(p.key!==key)fail('AGENT_PROFILE_CONFLICT');return p;}
+function readParticipantProfileByPath(file){const p=readJSON(file,'PARTICIPANT_PROFILE_REQUIRED');exact(p,['version','key','name','identityBasis','account','userId','registeredAt']);if(p.version!=='ksf.participant.v1'||typeof p.key!=='string'||!validDate(p.registeredAt))fail('INVALID_IDENTITY_FILE');const fields=participantInput(savedParticipantFields(p)),validKey=fields.identityBasis==='participant_registration'&&/^participant-registration:[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/.test(p.key)||p.key===stableParticipantKey(fields);if(!validKey)fail('INVALID_IDENTITY_FILE');return{...p,...fields};}
+export function readParticipantProfile(key,dir=PARTICIPANT_DIR){const p=readParticipantProfileByPath(participantFile(key,dir));if(p.key!==key)fail('PARTICIPANT_PROFILE_CONFLICT');return p;}
+export function registerAgentProfile(input,{profileDir=PROFILE_DIR}={}){const fields=agentInput(input),all=profiles(profileDir,'agent'),stable=stableAgentKey(fields);if(input.reuseKey){const prior=readAgentProfileByKey(input.reuseKey,profileDir);if(canonical(savedAgentFields(prior))!==canonical(fields))fail('AGENT_PROFILE_CONFLICT');return{changed:false,profile:agentSummary(prior)};}const same=all.filter(p=>p.name===fields.name&&p.role===fields.role);if(stable){const file=agentFile(stable,profileDir);if(fs.existsSync(file)){const prior=readAgentProfile(file);if(canonical(savedAgentFields(prior))!==canonical(fields))fail('AGENT_PROFILE_CONFLICT');return{changed:false,profile:agentSummary(prior)};}}if(same.length&&!stable&&!input.confirmNew)return{changed:false,needsReview:true,candidates:same.map(agentSummary)};const key=stable||'agent-registration:'+crypto.randomUUID(),file=agentFile(key,profileDir);const at=new Date().toISOString(),profile={version:'ksf.agent-self.v1',key,...fields,collectedAt:input.collectedAt??at,registeredAt:at};writeJSON(profileDir,file,profile);return{changed:true,profile:agentSummary(profile)};}
+export function checkAgentProfile(key,input,{profileDir=PROFILE_DIR}={}){const prior=readAgentProfileByKey(key,profileDir),fields=agentInput(input);if(canonical(savedAgentFields(prior))!==canonical(fields))fail('AGENT_PROFILE_CONFLICT');return{changed:false,matches:true,profile:agentSummary(prior)};}
+export function registerParticipantProfile(input,{participantDir=PARTICIPANT_DIR}={}){const fields=participantInput(input),all=profiles(participantDir,'participant'),stable=stableParticipantKey(fields);if(input.reuseKey){const prior=readParticipantProfile(input.reuseKey,participantDir);if(prior.name!==fields.name||canonical(savedParticipantFields(prior))!==canonical(fields))fail('PARTICIPANT_PROFILE_CONFLICT');return{changed:false,profile:participantSummary(prior)};}if(stable){const key=stable,file=participantFile(key,participantDir);if(fs.existsSync(file)){const prior=readParticipantProfile(key,participantDir);if(canonical(savedParticipantFields(prior))!==canonical(fields))fail('PARTICIPANT_PROFILE_CONFLICT');return{changed:false,profile:participantSummary(prior)};}}
+ const same=all.filter(p=>p.name===fields.name);if(same.length&&!input.confirmNew)return{changed:false,needsReview:true,candidates:same.map(participantSummary)};const key=stable||'participant-registration:'+crypto.randomUUID(),file=participantFile(key,participantDir);if(fs.existsSync(file))fail('PARTICIPANT_PROFILE_CONFLICT');const profile={version:'ksf.participant.v1',key,...fields,registeredAt:new Date().toISOString()};writeJSON(participantDir,file,profile);return{changed:true,profile:participantSummary(profile)};}
+export function checkParticipantProfile(key,input,{participantDir=PARTICIPANT_DIR}={}){const prior=readParticipantProfile(key,participantDir),fields=participantInput(input);if(canonical(savedParticipantFields(prior))!==canonical(fields))fail('PARTICIPANT_PROFILE_CONFLICT');return{changed:false,matches:true,profile:participantSummary(prior)};}
+// The caller must supply context from the authenticated adapter, never chat text.
+// This module validates its shape and scope; a JSON file is not an authentication proof.
+export function registerChannelParticipant(context,{agentProfile,participantDir=PARTICIPANT_DIR}={}){
+ exact(context,['version','botId','senderId','chatType','chatId','messageId']);
+ if(context.version!=='ksf.wecom-aibot-context.v1')fail('INVALID_CHANNEL_CONTEXT');
+ const botId=text(context.botId),senderId=text(context.senderId);text(context.messageId);
+ if(!['single','group'].includes(context.chatType))fail('INVALID_CHANNEL_CONTEXT');
+ if(context.chatType==='group'||context.chatId!=null)text(context.chatId);
+ if(!agentProfile)fail('AGENT_PROFILE_REQUIRED');
+ if(agentProfile.channel!=='wecom'||agentProfile.account?.platform!=='wecom'||agentProfile.account.accountId!==botId)fail('CHANNEL_AGENT_MISMATCH');
+ const fields={identityBasis:'channel_context',userId:null,account:normalizeParticipantAccount({platform:'wecom',tenantId:null,accountId:senderId,identifierType:'wecom_aibot_sender',scopeId:botId})};
+ const key=stableParticipantKey(fields),label='员工待绑定·'+key.split(':')[1].slice(0,12),file=participantFile(key,participantDir);
+ let result;
+ if(fs.existsSync(file)){
+  const prior=readParticipantProfile(key,participantDir);
+  if(prior.identityBasis!=='channel_context'||prior.userId!==null||canonical(prior.account)!==canonical(fields.account))fail('PARTICIPANT_PROFILE_CONFLICT');
+  result={changed:false,profile:participantSummary(prior)};
+ }else result=registerParticipantProfile({name:label,...fields,confirmNew:true},{participantDir});
+ return{...result,needsNameBinding:result.profile.name===label,displayNameAuthenticated:false,channelContextOriginVerified:false};
+}
+export function findParticipantProfiles(name,{participantDir=PARTICIPANT_DIR}={}){const q=text(name),rows=profiles(participantDir,'participant').filter(p=>p.name===q);return rows.map(participantSummary);}
+export function listIdentityProfiles({profileDir=PROFILE_DIR,participantDir=PARTICIPANT_DIR}={}){return{agents:profiles(profileDir,'agent').map(agentSummary),participants:profiles(participantDir,'participant').map(participantSummary)};}
+export function adoptLegacyAgentProfile(file,{profileDir=PROFILE_DIR}={}){const prior=readAgentProfile(file),target=agentFile(prior.key,profileDir);if(fs.existsSync(target)){if(canonical(readAgentProfile(target))!==canonical(prior))fail('AGENT_PROFILE_CONFLICT');return{changed:false,profile:agentSummary(prior),legacyPreserved:true};}writeJSON(profileDir,target,prior);return{changed:true,profile:agentSummary(prior),legacyPreserved:true};}
+export function bindOwnAgent(task,profile){if(!profile)fail('AGENT_PROFILE_REQUIRED');if(profile.role!=='workshop')fail('AGENT_ROLE_MISMATCH');const agent={key:profile.key,name:profile.name,identityBasis:profile.identityBasis,account:normalizeAgentAccount(profile.account),role:profile.role,runtimePlatform:profile.runtimePlatform??null,channel:profile.channel??null,collectedAt:profile.collectedAt??profile.registeredAt};if(task.agent!==undefined){exact(task.agent,['key','name','identityBasis','account','role','runtimePlatform','channel','collectedAt']);for(const key of ['key','name','identityBasis','role','runtimePlatform','channel','collectedAt'])if(task.agent[key]!==undefined&&task.agent[key]!==agent[key])fail('AGENT_IDENTITY_MISMATCH');if(task.agent.account!==undefined&&canonical(normalizeAgentAccount(task.agent.account))!==canonical(agent.account))fail('AGENT_IDENTITY_MISMATCH');}return{...task,agent};}
+export function bindRegisteredParticipant(input,profile){if(!profile)fail('PARTICIPANT_PROFILE_REQUIRED');if(!object(input)||Object.keys(input).some(k=>!['profileKey','name'].includes(k)))fail('INVALID_IDENTITY_FIELDS');if(input.profileKey!==profile.key||input.name&&input.name!==profile.name)fail('PARTICIPANT_PROFILE_CONFLICT');return{key:profile.key,name:profile.name,identityBasis:profile.identityBasis,...(profile.userId?{userId:profile.userId}:{}),account:normalizeParticipantAccount(profile.account)};}
+export function assertOwnSession(row,profile){if(!profile)fail('AGENT_PROFILE_REQUIRED');if(profile.role!=='workshop')fail('AGENT_ROLE_MISMATCH');const a=row?.meta?.attribution;if(a?.agentKey!==profile.key||a?.agentName!==profile.name||a?.agentRuntimePlatform!==(profile.runtimePlatform??null)||a?.agentChannel!==(profile.channel??null))fail('AGENT_SESSION_MISMATCH');}
+export function redactIdentity(value){if(Array.isArray(value))return value.map(redactIdentity);if(!object(value))return value;const hidden=new Set(['participantAccount','agentAccount','conversationKey','ownerId','userRef','accountId','tenantId','runtimeAgentId','wecomAppAgentId','employeeNumber','scopeId','botId','senderId']);return Object.fromEntries(Object.entries(value).filter(([k,v])=>!hidden.has(k)&&v!==undefined).map(([k,v])=>[k,k==='producer'&&object(v)?redactIdentity({...v,...(v.agent?{agent:{key:v.agent.key,name:v.agent.name,identityBasis:v.agent.identityBasis}}:{})}):redactIdentity(v)]));}
+export function executeIdentity(argv,options={}){
+ const[command,...args]=argv;let result;
+ if(command==='status'&&args.length===0)result={...listIdentityProfiles(options)};
+ else if(command==='register'&&args.length===2&&args[0]==='--input'&&path.isAbsolute(args[1]))result=registerAgentProfile(JSON.parse(fs.readFileSync(args[1],'utf8')),options);
+ else if(command==='adopt-agent'&&args.length===2&&args[0]==='--input'&&path.isAbsolute(args[1]))result=adoptLegacyAgentProfile(args[1],options);
+ else if(command==='check'&&args.length===4&&args[0]==='--agent-profile'&&args[2]==='--input'&&path.isAbsolute(args[3]))result=checkAgentProfile(args[1],JSON.parse(fs.readFileSync(args[3],'utf8')),options);
+ else if(command==='register-employee'&&args.length===2&&args[0]==='--input'&&path.isAbsolute(args[1])){
+  const input=JSON.parse(fs.readFileSync(args[1],'utf8'));
+  if(input?.identityBasis==='channel_context'||input?.account?.identifierType==='wecom_aibot_sender')fail('TRUSTED_IDENTITY_TOOL_REQUIRED');
+  result=registerParticipantProfile(input,options);
+ }else if(command==='register-channel-employee'){
+  fail('TRUSTED_IDENTITY_TOOL_REQUIRED');
+ }else if(command==='check-employee'&&args.length===4&&args[0]==='--participant-profile'&&args[2]==='--input'&&path.isAbsolute(args[3]))result=checkParticipantProfile(args[1],JSON.parse(fs.readFileSync(args[3],'utf8')),options);
+ else if(command==='lookup-employee'&&args.length===2&&args[0]==='--name')result={candidates:findParticipantProfiles(args[1],options)};
+ else fail('USAGE');
+ return{skill:'ksf-workshop-runtime',command,invocationId:crypto.randomUUID(),at:new Date().toISOString(),readOnly:!['register','register-employee','register-channel-employee','adopt-agent'].includes(command),networkRequests:0,databaseWrites:0,ok:true,...result,accountAuthenticated:false};
+}
+if(process.argv[1]&&path.resolve(process.argv[1])===self){const base={skill:'ksf-workshop-runtime',command:process.argv[2],invocationId:crypto.randomUUID(),at:new Date().toISOString(),networkRequests:0,databaseWrites:0};try{if(process.platform!=='linux')fail('LINUX_REQUIRED');console.log(JSON.stringify(executeIdentity(process.argv.slice(2)),null,2));}catch(e){console.error(JSON.stringify({...base,ok:false,error:{code:/^[A-Z][A-Z0-9_]+$/.test(e.code||'')?e.code:'IDENTITY_FAILED',message:'身份处理停止;未输出账号值、配置原文或凭据。'}},null,2));process.exitCode=1;}}

+ 133 - 0
skills/ksf-workshop-runtime/scripts/ksf-secure.mjs

@@ -0,0 +1,133 @@
+import fs from 'node:fs';
+import path from 'node:path';
+import crypto from 'node:crypto';
+import os from 'node:os';
+import { fileURLToPath, pathToFileURL } from 'node:url';
+import { spawnSync } from 'node:child_process';
+
+const self = fileURLToPath(import.meta.url);
+const sha256 = bytes => crypto.createHash('sha256').update(bytes).digest('hex');
+const ENTRY_HASH = '7ef128f7d022bfbc15c0ec813bfd87995c6297c6efe407a563c840f014aba477';
+const LOCK_HASH = '2605cbe8fd7be43b5969a4991f978dc0768dee5b4d65d18f7af15f75e9004a79';
+const WORKSHOP_ENTRY_HASH = '95d05ac21310b7ccdd204cd5d01b4dcb7fa6944694acda089f1ff32eacda4fc7';
+const IDENTITY_MODULE_HASH = '9224ef89a96425890d54961fda1f4fcae3403aa7c753fe1d70ac62082f2ff77e';
+const WORKSHOP_COMMANDS = ['workshop-start','workshop-save','project-create','project-progress','concept-save','project-status','workshop-import','workshop-dedup','workshop-search','workshop-read','workshop-contract'];
+const fail = code => { throw Object.assign(new Error(code), { code }); };
+const requireThat = (value, code) => { if (!value) fail(code); };
+
+export function parseArgs(argv) {
+  const [command, ...rest] = argv, args = { command };
+  requireThat(['status','demo','connection','ingest','report-preview','report-submit',...WORKSHOP_COMMANDS].includes(command), 'USAGE');
+  for (let i = 0; i < rest.length; i += 2) {
+    const key = rest[i], value = rest[i + 1];
+    requireThat(['--input','--request-id','--agent-profile'].includes(key) && value && !value.startsWith('--') && !args[key], 'USAGE');
+    args[key] = value;
+  }
+  requireThat(['ingest','report-preview','report-submit',...WORKSHOP_COMMANDS].includes(command) === Boolean(args['--input']), 'USAGE');
+  if(WORKSHOP_COMMANDS.includes(command))requireThat(Boolean(args['--request-id']), 'BAD_REQUEST_ID');
+  if(args['--agent-profile'])requireThat(WORKSHOP_COMMANDS.includes(command)&&/^agent-(?:registration:[a-f0-9-]{36}|account:[a-f0-9]{64})$/.test(args['--agent-profile']),'INVALID_AGENT_KEY');
+  if(WORKSHOP_COMMANDS.includes(command)&&!['project-status','workshop-search','workshop-read','workshop-contract','workshop-dedup'].includes(command))requireThat(Boolean(args['--agent-profile']),'AGENT_PROFILE_REQUIRED');
+  if (args['--input']) requireThat(path.isAbsolute(args['--input']), 'INPUT_PATH_MUST_BE_ABSOLUTE');
+  if (args['--request-id']) requireThat(/^[\p{L}\p{N}_:@.-]{1,100}$/u.test(args['--request-id']), 'BAD_REQUEST_ID');
+  return args;
+}
+
+function readPrivateKey(directory) {
+  requireThat(/^\/opt\/data\/ksf-key-receive-20261031-[A-Za-z0-9]+$/.test(directory), 'BAD_KEY_DIRECTORY');
+  requireThat(fs.realpathSync(directory) === directory, 'KEY_DIRECTORY_IS_SYMLINK');
+  const dir = fs.lstatSync(directory), file = path.join(directory, 'recipient-private.pem'), stat = fs.lstatSync(file);
+  requireThat(dir.isDirectory() && dir.uid === process.geteuid() && (dir.mode & 0o777) === 0o700, 'BAD_KEY_DIRECTORY_PERMISSIONS');
+  requireThat(stat.isFile() && !stat.isSymbolicLink() && stat.uid === process.geteuid() && (stat.mode & 0o777) === 0o600, 'BAD_KEY_FILE_PERMISSIONS');
+  const bytes = fs.readFileSync(file);
+  try { return crypto.createPrivateKey(bytes); } finally { bytes.fill(0); }
+}
+
+// Test dependencies are module arguments only; the executable accepts no override flags.
+export async function executeSecure(argv, dependencies = {}) {
+  const args = parseArgs(argv);
+  const skillDir = dependencies.skillDir || path.resolve(path.dirname(self), '..');
+  const readBytes = dependencies.readBytes || (file => fs.readFileSync(file));
+  const spawn = dependencies.spawn || spawnSync;
+  const workshop = WORKSHOP_COMMANDS.includes(args.command);
+  const entry = path.join(skillDir, 'scripts', workshop ? 'ksf-workshop.mjs' : 'ksf.mjs');
+  const expectedEntryHash = workshop ? WORKSHOP_ENTRY_HASH : ENTRY_HASH;
+  requireThat(sha256(readBytes(entry)) === expectedEntryHash, 'ENTRY_HASH_MISMATCH');
+  if(workshop)requireThat(sha256(readBytes(path.join(skillDir,'scripts','ksf-identity.mjs'))) === IDENTITY_MODULE_HASH, 'IDENTITY_MODULE_HASH_MISMATCH');
+  requireThat(sha256(readBytes(path.join(skillDir, 'runtime-lock.json'))) === LOCK_HASH, 'RUNTIME_LOCK_HASH_MISMATCH');
+  const inheritedEnv = dependencies.inheritedEnv || process.env;
+  const env = { ...inheritedEnv };
+  for (const name of Object.keys(env)) if (name.startsWith('KSF_')) delete env[name];
+  Object.assign(env, {
+    KSF_ADAPT_DIR: dependencies.runtimeDir || '/opt/data/ksf-adapt-training-20261031',
+    KSF_R7_SKILL_DIR: dependencies.r7Dir || '/opt/data/ksf-baseline/r7/unpacked/skill',
+  });
+  let payload = null, binding = null;
+  try {
+    if (args.command !== 'demo') {
+      binding = JSON.parse(readBytes(path.join(skillDir, 'credential-binding.json')));
+      requireThat(binding.version === 'ksf.encrypted-entry.v1' && binding.purpose === 'KSF-credential-receive-20261031', 'BAD_BINDING');
+      requireThat(binding.host === (dependencies.hostname || os.hostname()), 'WRONG_CONTAINER');
+      requireThat(/^\/opt\/data\/.+/.test(binding.envelopeFile)
+        && /^\/opt\/data\/.+/.test(binding.decryptModule), 'BAD_BINDING_PATHS');
+      requireThat(/^[a-f0-9]{64}$/.test(binding.envelopeSha256)
+        && /^[a-f0-9]{64}$/.test(binding.decryptModuleSha256)
+        && /^[a-f0-9]{64}$/.test(binding.publicKeySha256), 'BAD_BINDING_HASHES');
+      const envelopeBytes = readBytes(binding.envelopeFile);
+      requireThat(envelopeBytes.length < 32768 && sha256(envelopeBytes) === binding.envelopeSha256, 'ENVELOPE_HASH_MISMATCH');
+      const envelope = JSON.parse(envelopeBytes);
+      requireThat(envelope.header?.recipientHost === binding.host
+        && envelope.header?.recipientDirectory === binding.keyDirectory
+        && envelope.header?.publicKeySha256 === binding.publicKeySha256, 'ENVELOPE_BINDING_MISMATCH');
+      requireThat(sha256(readBytes(binding.decryptModule)) === binding.decryptModuleSha256, 'DECRYPT_MODULE_HASH_MISMATCH');
+      const module = await (dependencies.importDecrypt || (file => import(pathToFileURL(file).href)))(binding.decryptModule);
+      const key = (dependencies.readPrivateKey || readPrivateKey)(binding.keyDirectory);
+      payload = module.decryptEnvelope(envelope, key, { host: binding.host, directory: binding.keyDirectory,
+        publicKeySha256: binding.publicKeySha256 });
+      Object.assign(env, payload.env);
+    }
+    const child = spawn(process.execPath, [entry, ...argv], {
+      env, encoding: 'utf8', timeout: 600000, maxBuffer: 4 * 1024 * 1024, windowsHide: true,
+    });
+    requireThat(!child.error && [0,1].includes(child.status), 'ENTRY_PROCESS_FAILED');
+    if (payload) requireThat(!String(child.stdout).includes(payload.env.KSF_SERVICE_TOKEN)
+      && !String(child.stderr).includes(payload.env.KSF_SERVICE_TOKEN), 'OUTPUT_CONTAINS_SECRET');
+    const result = JSON.parse(child.status === 0 ? child.stdout : child.stderr);
+    requireThat(result.skill === 'ksf-workshop-runtime' && result.entry === entry
+      && result.entrySha256 === expectedEntryHash && result.command === args.command
+      && typeof result.invocationId === 'string' && /^[a-f0-9-]{36}$/.test(result.invocationId)
+      && (!args['--request-id'] || result.requestId === args['--request-id']), 'ENTRY_OUTPUT_MISMATCH');
+    requireThat(typeof result.ok === 'boolean' && (child.status === 0) === result.ok, 'ENTRY_EXIT_STATUS_MISMATCH');
+    const output = {
+      skill: 'ksf-workshop-runtime', invocationId: crypto.randomUUID(), at: new Date().toISOString(),
+      wrapper: self, wrapperSha256: sha256(readBytes(self)), command: args.command,
+      ...(args['--request-id'] ? { requestId: args['--request-id'] } : {}),
+      ok: result.ok, credentialMode: payload ? 'encrypted-file-decrypted-per-command' : 'not-needed-for-demo',
+      credentialLoadedForChild: Boolean(payload), credentialWrittenToDisk: false,
+      gatewayEnvironmentModified: false, gatewayRestarted: false,
+      ...(payload ? { credentialExpiresAt: payload.expiresAt, envelopeSha256: binding.envelopeSha256 } : {}),
+      entryExitCode: child.status, entryResult: result,
+    };
+    if (payload) requireThat(!JSON.stringify(output).includes(payload.env.KSF_SERVICE_TOKEN), 'OUTPUT_CONTAINS_SECRET');
+    return output;
+  } finally {
+    delete env.KSF_SERVICE_TOKEN;
+    if (payload) delete payload.env.KSF_SERVICE_TOKEN;
+  }
+}
+
+if (process.argv[1] && path.resolve(process.argv[1]) === self) {
+  const base = { skill: 'ksf-workshop-runtime', invocationId: crypto.randomUUID(), at: new Date().toISOString(),
+    wrapper: self, wrapperSha256: sha256(fs.readFileSync(self)) };
+  try {
+    requireThat(process.platform === 'linux', 'LINUX_REQUIRED');
+    const result = await executeSecure(process.argv.slice(2));
+    console.log(JSON.stringify(result, null, 2));
+    if (!result.ok) process.exitCode = 1;
+  } catch (error) {
+    console.error(JSON.stringify({ ...base, ok: false, error: {
+      code: /^[A-Z][A-Z0-9_]+$/.test(error.code || '') ? error.code : 'SECURE_ENTRY_FAILED',
+      message: '执行停止;未显示凭据或原始异常。凭据过期需由管理端续期或轮换;请勿改指纹或绕过校验。',
+    } }, null, 2));
+    process.exitCode = 1;
+  }
+}

+ 105 - 0
skills/ksf-workshop-runtime/scripts/ksf-workshop.mjs

@@ -0,0 +1,105 @@
+import fs from 'node:fs';
+import path from 'node:path';
+import crypto from 'node:crypto';
+import {fileURLToPath} from 'node:url';
+import {readAgentProfileByKey,readParticipantProfile,bindOwnAgent,bindRegisteredParticipant,assertOwnSession,redactIdentity} from './ksf-identity.mjs';
+const self=fileURLToPath(import.meta.url);
+const commands={
+ 'workshop-start':'ksfStartWorkshopSession','workshop-save':'ksfSaveWorkshopTask',
+ 'project-create':'ksfCreateProjectFromOpportunity','project-progress':'ksfTransitionProject',
+ 'concept-save':'ksfSaveWorkshopConcept','project-status':'ksfGetWorkshopProject','workshop-search':'ksfSearchRecords',
+ 'workshop-import':null,'workshop-dedup':'ksfCheckDuplicates','workshop-read':'ksfGetRecord','workshop-contract':'ksfGetIngestContract'
+};
+const sha=x=>crypto.createHash('sha256').update(x).digest('hex');
+const fail=(code,message)=>{throw Object.assign(new Error(message||code),{code});};
+const canonical=x=>x&&typeof x==='object'?Array.isArray(x)?'['+x.map(canonical).join(',')+']':'{'+Object.keys(x).sort().map(k=>JSON.stringify(k)+':'+canonical(x[k])).join(',')+'}':JSON.stringify(x);
+export async function runWorkshop(command,task,{requestId,call,agentProfile,participantProfile}={}){
+ if(!Object.hasOwn(commands,command)||!task||typeof task!=='object'||Array.isArray(task))fail('INVALID_INPUT');
+ if(typeof requestId!=='string'||!requestId||task.requestId&&task.requestId!==requestId)fail('BAD_REQUEST_ID');
+ if(!['project-status','workshop-search','workshop-read','workshop-contract','workshop-dedup'].includes(command)&&!agentProfile)fail('AGENT_PROFILE_REQUIRED');
+ if(command==='workshop-start'&&!participantProfile)fail('PARTICIPANT_PROFILE_REQUIRED');
+ if(agentProfile){
+  if(!['project-status','workshop-search','workshop-read','workshop-contract','workshop-dedup'].includes(command)&&agentProfile.role!=='workshop')fail('AGENT_ROLE_MISMATCH');
+  if(command==='workshop-start'){
+   task={...task,participant:bindRegisteredParticipant(task.participant,participantProfile)};
+   task=bindOwnAgent(task,agentProfile);
+   const contract=await call('ksfGetIngestContract',{});
+   if(contract.workshop?.identitySchema!=='ksf.agent-self.v1')fail('IDENTITY_CAPABILITY_UNAVAILABLE','服务端身份扩展尚未就绪;未创建会话');
+   if(task.participant.account?.identifierType==='wecom_aibot_sender'){
+    if(contract.workshop?.channelIdentitySchema!=='ksf.wecom-aibot-sender.v1')fail('CHANNEL_IDENTITY_CAPABILITY_UNAVAILABLE');
+    if(task.participant.account.scopeId!==agentProfile.account.accountId||agentProfile.account.platform!=='wecom'||task.conversation?.channel!=='wecom')fail('CHANNEL_AGENT_MISMATCH');
+   }
+  }
+  else if(['workshop-save','project-create','project-progress','concept-save'].includes(command)){
+   const current=await call('ksfGetRecord',{className:'KfSession',objectId:task.sessionId});
+   assertOwnSession(current.record,agentProfile);
+  }else if(command==='workshop-import'&&agentProfile.role!=='workshop')fail('AGENT_ROLE_MISMATCH');
+ }
+ if(command!=='workshop-import')return redactIdentity(await call(commands[command],{...task,requestId}));
+ const allowed=['source','records','requestId'];if(Object.keys(task).some(k=>!allowed.includes(k)))fail('INVALID_INPUT');
+ if(!task.source?.rawText||typeof task.source.rawText!=='string'||!Array.isArray(task.records)||task.records.length<1||task.records.length>200)fail('INVALID_INPUT','需要原始资料 source 和 1–200 条 records');
+ const contract=await call('ksfGetIngestContract',{});const classes=new Map(contract.allowedClasses.map(x=>[x.className,x]));
+ const excluded=['KfArtifact','KfConcept','KfFormulaItem','KfOpportunity','KfProject','KfSession','KfStageEvent'];
+ const keys=new Set();
+ for(const item of task.records){
+   if(!item.itemKey||keys.has(item.itemKey)||!item.externalKey||!classes.has(item.className)||excluded.includes(item.className)||!item.data||item.data.dataMode!==task.source.dataMode)fail('INVALID_INPUT','条目键、类型或取得模式不合法;报告/机会/方案请用对应工作坊命令');keys.add(item.itemKey);
+ }
+ if(contract.deduplication?.schema!=='ksf.ingest-dedup.v1')fail('DEDUP_CAPABILITY_UNAVAILABLE','先升级查重服务,未登记来源或提交导入');
+ const deduplication=await call('ksfCheckDuplicates',{source:task.source,records:task.records});
+ if(deduplication.schema!=='ksf.ingest-dedup.v1'||deduplication.readOnly!==true)fail('DEDUP_RECEIPT_MISMATCH');
+ const registered=await call('ksfRegisterSource',{requestId:requestId+':source',source:task.source,contentHash:sha(Buffer.from(task.source.rawText,'utf8'))});
+ const refs={},pending=task.records.slice(),items=[];
+ const resolve=value=>{
+  if(Array.isArray(value))return value.map(resolve);
+  if(value&&typeof value==='object'){
+   if(Object.keys(value).length===1&&typeof value.$item==='string'){if(!refs[value.$item])fail('UNRESOLVED_DEPENDENCY');return refs[value.$item];}
+   return Object.fromEntries(Object.entries(value).map(([k,v])=>[k,resolve(v)]));
+  }return value;
+ };
+ let active=null;
+ try { while(pending.length){
+  let progress=false;
+  for(let i=0;i<pending.length;i++){
+   const item=pending[i];if((item.dependsOn||[]).some(k=>!refs[k]))continue;
+   active={itemKey:item.itemKey,className:item.className,requestId:requestId+':item:'+item.itemKey};
+   const record=resolve(item.data);const def=classes.get(item.className);if(def.fields.some(x=>x.name==='sourceRef'))record.sourceRef={__type:'Pointer',className:'KfSource',objectId:registered.sourceId};
+   const p={requestId:requestId+':item:'+item.itemKey,schemaVersion:contract.schemaVersion,sourceId:registered.sourceId,producer:{skillName:'ksf-workshop-runtime',skillVersion:'2026.11.02',...(agentProfile?{agent:bindOwnAgent({},agentProfile).agent}:{})},items:[{itemKey:item.itemKey,className:item.className,externalKey:item.externalKey,data:record}]};
+   const checked=await call('ksfValidateImport',p);if(!checked.valid)fail(checked.itemResults?.[0]?.errors?.[0]?.code||'VALIDATION_FAILED','结构化条目预检失败,保留已返回编号和业务键');
+   const result=await call('ksfSubmitImport',p);active.jobId=result.jobId;const receipt=await call('ksfGetImportResult',{jobId:result.jobId,limit:200});const row=receipt.items?.[0];
+   if(receipt.job?.status!=='succeeded'||receipt.items?.length!==1||receipt.nextCursor||row.itemKey!==item.itemKey||row.status!=='succeeded'||row.snapshotState!=='available'||!row.objectId)fail('RECEIPT_MISMATCH');
+   refs[item.itemKey]={__type:'Pointer',className:item.className,objectId:row.objectId};items.push({itemKey:item.itemKey,className:item.className,objectId:row.objectId,jobId:result.jobId,status:row.status,operation:row.operation});
+   pending.splice(i--,1);progress=true;active=null;
+  }
+  if(!progress)fail('UNRESOLVED_DEPENDENCY','依赖缺失或循环;保留业务键,不自动换键重试');
+ }} catch(error) {
+   error.partial={sourceId:registered.sourceId,items,pendingItemKeys:pending.map(x=>x.itemKey),...(active?{active}:{}),status:'stopped',dataMode:task.source.dataMode};
+   throw error;
+ }
+ return{sourceId:registered.sourceId,sourceOperation:registered.operation,items,counts:{created:items.filter(x=>x.operation==='created').length,unchanged:items.filter(x=>x.operation==='unchanged').length},deduplication:{schema:deduplication.schema,checked:deduplication.items.length},status:'succeeded',dataMode:task.source.dataMode,formalReportPublished:false};
+}
+export function createWorkshopCaller({token,url='https://kangshifu.fmode.cn/api/functions',appId='FKieRbIoGt_main',fetchImpl=fetch}){
+ if(!token)fail('MISSING_SERVICE_TOKEN');if(url!=='https://kangshifu.fmode.cn/api/functions'||appId!=='FKieRbIoGt_main')fail('TARGET_MISMATCH');
+ return async(name,params)=>{
+  let r;try{r=await fetchImpl(url,{method:'POST',headers:{'Content-Type':'application/json','X-Parse-Application-Id':appId,'X-Ksf-Service-Token':token},body:JSON.stringify({path:'/'+name,params}),redirect:'error',signal:AbortSignal.timeout(45000)});}catch{fail('REMOTE_REQUEST_UNCERTAIN','请求结果未知,请保持原 requestId 查回状态;不自动重试或换键');}
+  const result=(await r.json().catch(()=>null))?.result;
+  if(!r.ok||result?.ok!==true||result.errors?.length)fail(result?.errors?.[0]?.code||'REMOTE_FAILED',result?.errors?.[0]?.message||'服务调用失败');return result.data;
+ };
+}
+if(process.argv[1]&&path.resolve(process.argv[1])===self){
+ const base={skill:'ksf-workshop-runtime',invocationId:crypto.randomUUID(),at:new Date().toISOString(),entry:self,entrySha256:sha(fs.readFileSync(self))};
+ try{
+  const [command,...args]=process.argv.slice(2);const flags={};for(let i=0;i<args.length;i+=2){if(!['--input','--request-id','--agent-profile'].includes(args[i])||!args[i+1]||flags[args[i]])fail('USAGE');flags[args[i]]=args[i+1];}
+  if(!flags['--input']||!path.isAbsolute(flags['--input']))fail('INPUT_PATH_MUST_BE_ABSOLUTE');
+  const task=JSON.parse(fs.readFileSync(flags['--input'],'utf8'));
+  const readOnly=['project-status','workshop-search','workshop-read','workshop-contract','workshop-dedup'].includes(command);
+  if(flags['--agent-profile']&&!/^agent-(?:registration:[a-f0-9-]{36}|account:[a-f0-9]{64})$/.test(flags['--agent-profile']))fail('INVALID_AGENT_KEY');
+  if(!readOnly&&!flags['--agent-profile'])fail('AGENT_PROFILE_REQUIRED');
+  const agentProfile=flags['--agent-profile']?readAgentProfileByKey(flags['--agent-profile']):null;
+  if(command==='workshop-start'&&!task.participant?.profileKey)fail('PARTICIPANT_PROFILE_REQUIRED');
+  const participantProfile=command==='workshop-start'?readParticipantProfile(task.participant?.profileKey):null;
+  if(command==='workshop-start'&&!participantProfile)fail('PARTICIPANT_PROFILE_REQUIRED');
+  const result=await runWorkshop(command,task,{requestId:flags['--request-id'],agentProfile,participantProfile,call:createWorkshopCaller({token:process.env.KSF_SERVICE_TOKEN})});
+  const out=JSON.stringify({...base,command,requestId:flags['--request-id'],ok:true,readOnly,...(agentProfile?{executingAgent:{key:agentProfile.key,name:agentProfile.name,identityBasis:agentProfile.identityBasis}}:{}),result},null,2);
+  if(process.env.KSF_SERVICE_TOKEN&&out.includes(process.env.KSF_SERVICE_TOKEN))fail('OUTPUT_CONTAINS_SECRET');console.log(out);
+ }catch(e){const out=JSON.stringify({...base,command:process.argv[2],requestId:process.argv[process.argv.indexOf('--request-id')+1],ok:false,error:{code:/^[A-Z][A-Z0-9_]+$/.test(e.code||'')?e.code:'WORKSHOP_FAILED',message:'执行停止,保留原任务及业务键核查;未输出原始异常或凭据。'},...(e.partial?{partial:e.partial}:{})},null,2);console.error(process.env.KSF_SERVICE_TOKEN&&out.includes(process.env.KSF_SERVICE_TOKEN)?JSON.stringify({...base,ok:false,error:{code:'OUTPUT_CONTAINS_SECRET'}}):out);process.exitCode=1;}
+}

+ 161 - 0
skills/ksf-workshop-runtime/scripts/ksf.mjs

@@ -0,0 +1,161 @@
+import fs from 'node:fs';
+import path from 'node:path';
+import crypto from 'node:crypto';
+import { fileURLToPath, pathToFileURL } from 'node:url';
+
+const skillDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
+const runtimeDir = path.resolve(process.env.KSF_ADAPT_DIR || '/opt/data/ksf-adapt-training-20261031');
+const r7Dir = path.resolve(process.env.KSF_R7_SKILL_DIR || '/opt/data/ksf-baseline/r7/unpacked/skill');
+const hash = bytes => crypto.createHash('sha256').update(bytes).digest('hex');
+const base = {
+  skill: 'ksf-workshop-runtime', invocationId: crypto.randomUUID(), at: new Date().toISOString(),
+  entry: fileURLToPath(import.meta.url), entrySha256: hash(fs.readFileSync(fileURLToPath(import.meta.url))),
+};
+const fail = (code, message, details) => { throw Object.assign(new Error(message), { code, details }); };
+const object = value => value && typeof value === 'object' && !Array.isArray(value);
+
+function argsOf(argv) {
+  const [command, ...rest] = argv;
+  if (!['status', 'demo', 'connection', 'ingest', 'report-preview', 'report-submit'].includes(command)) {
+    fail('USAGE', '用法:ksf.mjs status|demo|connection|ingest|report-preview|report-submit [--input 文件] [--request-id 消息标记]');
+  }
+  const args = { command };
+  for (let i = 0; i < rest.length; i += 2) {
+    const key = rest[i];
+    if (!['--input', '--request-id'].includes(key) || !rest[i + 1] || rest[i + 1].startsWith('--') || args[key] != null) {
+      fail('USAGE', '参数名称、参数值或重复参数不正确');
+    }
+    args[key] = rest[i + 1];
+  }
+  if (args['--request-id'] && !/^[\p{L}\p{N}_:@.-]{1,100}$/u.test(args['--request-id'])) fail('USAGE', '消息标记只接受100字以内的文字、数字和 _ : @ . -');
+  if (['ingest', 'report-preview', 'report-submit'].includes(command) !== Boolean(args['--input'])) fail('USAGE', '写入命令必须有 --input;只读命令不接受 --input');
+  return args;
+}
+
+function verifyTree(root, entries) {
+  return Object.entries(entries).map(([file, expected]) => {
+    const target = path.resolve(root, file);
+    if (!target.startsWith(root + path.sep)) fail('BAD_LOCK', '指纹清单包含越界路径');
+    let actual = null;
+    try { actual = hash(fs.readFileSync(target)); } catch { /* 缺失与不符都阻止加载 */ }
+    return { file, ok: actual === expected, ...(actual === expected ? {} : { expected, actual }) };
+  });
+}
+
+function verifyInstallation() {
+  const lock = JSON.parse(fs.readFileSync(path.join(skillDir, 'runtime-lock.json'), 'utf8'));
+  const runtime = verifyTree(runtimeDir, lock.runtime), r7 = verifyTree(r7Dir, lock.r7);
+  const errors = [...runtime.filter(x => !x.ok).map(x => ({ tree: 'runtime', ...x })),
+    ...r7.filter(x => !x.ok).map(x => ({ tree: 'r7', ...x }))];
+  return { verified: !errors.length, runtimeDir, r7Dir,
+    runtimeFiles: { passed: runtime.filter(x => x.ok).length, total: runtime.length },
+    r7Files: { passed: r7.filter(x => x.ok).length, total: r7.length }, errors };
+}
+
+function requireService() {
+  if (!process.env.KSF_SERVICE_TOKEN?.trim()) fail('MISSING_SERVICE_TOKEN', '未配置 KSF_SERVICE_TOKEN;请在命令进程环境中注入,勿发送到聊天');
+}
+
+function readTask(file, allowed) {
+  let task;
+  try { task = JSON.parse(fs.readFileSync(path.resolve(file), 'utf8')); }
+  catch { fail('INVALID_INPUT', '输入文件无法读取或不是有效 JSON'); }
+  if (!object(task)) fail('INVALID_INPUT', '任务根节点必须是对象');
+  if (Object.keys(task).some(key => !allowed.includes(key))) fail('INVALID_INPUT', '任务包含本命令不接受的根字段,请按技能中的输入约定提供');
+  return task;
+}
+
+async function moduleAt(file) {
+  return import(pathToFileURL(path.join(runtimeDir, file)).href);
+}
+
+async function reportTask(file, command) {
+  const task = readTask(file, ['reportDir', 'seriesKey', 'version', 'reportType', 'dataMode', 'stageCode', 'artifactSubtype', 'previewId']);
+  const codes = await moduleAt('client/contract-constants.mjs');
+  if (typeof task.reportDir !== 'string' || !path.isAbsolute(task.reportDir)
+      || typeof task.seriesKey !== 'string' || !task.seriesKey.trim()
+      || !Number.isInteger(task.version) || task.version < 1
+      || !codes.REPORT_TYPES.includes(task.reportType) || !codes.DATA_MODES.includes(task.dataMode)
+      || !codes.STAGE_CODES.includes(task.stageCode)
+      || (task.reportType === codes.AUXILIARY_TYPE && !task.artifactSubtype)) {
+    fail('INVALID_REPORT', '报告目录、系列键、版本、报告类型、数据模式、阶段或附属类型不完整/不合法');
+  }
+  if (!fs.existsSync(path.join(task.reportDir, 'report.html'))) fail('MISSING_REPORT', '报告目录中没有 report.html');
+  if (command === 'report-submit' && task.dataMode === 'sample') fail('SAMPLE_DISPLAY_ONLY', '本轮培训 sample 报告只作展示,不能提交到正式看板;不得更改数据标签绕过');
+  if (command === 'report-submit' && (typeof task.previewId !== 'string' || !task.previewId)) fail('MISSING_PREVIEW', '须提供服务端返回的 previewId,且由员工在确认页完成确认');
+  requireService();
+  const bridge = await moduleAt('client/report-bridge.mjs');
+  if (command === 'report-preview') {
+    const preview = await bridge.previewReport(task);
+    return { ok: true, operation: 'preview-created', employeeActionRequired: true,
+      previewId: preview.previewId, confirmationUrl: preview.confirmationUrl,
+      reportIdentity: preview.reportIdentity, dataMode: task.dataMode, ingested: false, published: false };
+  }
+  const result = await bridge.submitConfirmedReport({ ...task, skillDir: r7Dir });
+  return { ok: !result.fatal, operation: 'report-submit', steps: result.steps,
+    jobId: result.artifacts?.jobId, artifactId: result.artifacts?.artifactId,
+    cardId: result.artifacts?.cardId, guard: result.guard,
+    ingested: result.steps?.import === 'ok', published: result.steps?.publish === 'ok',
+    ...(result.fatal ? { error: { code: result.fatal.code, step: result.fatal.step, message: result.fatal.reason } } : {}) };
+}
+
+async function run(args) {
+  const installation = verifyInstallation();
+  if (args.command === 'status') return {
+    ok: installation.verified, readOnly: true, networkRequests: 0, installation,
+    serviceTokenConfigured: Boolean(process.env.KSF_SERVICE_TOKEN?.trim()),
+    connectionVerified: false, note: '本次仅检查本地安装;凭据是否有效需另跑 connection',
+  };
+  if (!installation.verified) fail('INSTALLATION_MISMATCH', '文件缺失或指纹不符,未加载连接模块、未发出网络请求', installation);
+  if (args.command === 'demo') {
+    const metadata = JSON.parse(fs.readFileSync(path.join(runtimeDir, 'training-demo/报告样例/metadata.json'), 'utf8'));
+    return { ok: true, readOnly: true, networkRequests: 0, dataMode: 'sample',
+      report: { localFile: path.join(runtimeDir, 'training-demo/报告样例/report.html'),
+        url: 'https://s3.fmode.cn/user/DpPsnlBm0K/ksf-training-20261031/report-demo/index.html',
+        sha256: metadata.reportContentHash, sizeBytes: metadata.sizeBytes,
+        preparedBeforeConversation: true, hasRealResearchEvidence: false, publishedToDashboard: false },
+      dashboard: ['https://kangshifu.fmode.cn/', 'https://kangshifu.fmode.cn/pool',
+        'https://kangshifu.fmode.cn/stages', 'https://kangshifu.fmode.cn/report'],
+      linksCheckedThisRun: false, note: '这是预先准备的样例;链接来自既有培训配置,本命令未访问网页或核查现有看板记录' };
+  }
+  if (args.command.startsWith('report-')) return reportTask(args['--input'], args.command);
+  requireService();
+  if (args.command === 'connection') {
+    const { createGuardedClient } = await moduleAt('client/guarded-client.mjs');
+    const contract = await createGuardedClient({ sessionToken: '' }).invoke('ksfGetIngestContract', {});
+    if (!Array.isArray(contract?.allowedClasses) || !contract.allowedClasses.length) fail('CONTRACT_MISSING', '未读到受限身份合同');
+    return { ok: true, readOnly: true, identity: 'restricted-service', schemaVersion: contract.schemaVersion,
+      release: contract.release, allowedClassCount: contract.allowedClasses.length,
+      employeeAccountsProvisioned: contract.capabilities?.employeeAccountsProvisioned };
+  }
+  const task = readTask(args['--input'], ['requestKey', 'source', 'runKey', 'run', 'records', 'producer']);
+  const { ingestCollectedRecords } = await moduleAt('client/collection-bridge.mjs');
+  const result = await ingestCollectedRecords(task);
+  return { ok: true, operation: 'collection-ingested', sourceId: result.sourceId, runId: result.runId,
+    jobId: result.jobId, status: result.status, counts: result.counts,
+    items: result.items.map(({ itemKey, status, objectId }) => ({ itemKey, status, objectId })), published: false };
+}
+
+function redact(value) {
+  let text = JSON.stringify(value, null, 2);
+  for (const key of ['KSF_SERVICE_TOKEN', 'KSF_PARSE_SESSION_TOKEN', 'KSF_PARSE_MASTER_KEY']) {
+    const secret = process.env[key];
+    if (secret) text = text.split(JSON.stringify(secret).slice(1, -1)).join('[已隐藏]');
+  }
+  return text;
+}
+
+try {
+  const args = argsOf(process.argv.slice(2));
+  base.command = args.command;
+  if (args['--request-id']) base.requestId = args['--request-id'];
+  const result = await run(args);
+  console.log(redact({ ...base, ...result }));
+  if (!result.ok) process.exitCode = 1;
+} catch (error) {
+  console.error(redact({ ...base, ok: false, error: {
+    code: typeof error.code === 'string' ? error.code : 'EXECUTION_FAILED',
+    message: error.message || '执行失败', ...(error.details ? { details: error.details } : {}),
+  } }));
+  process.exitCode = 1;
+}

+ 170 - 0
test/workshop-identity.test.js

@@ -0,0 +1,170 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import fs from 'node:fs';
+import os from 'node:os';
+import path from 'node:path';
+import {registerAgentProfile,readAgentProfileByKey,checkAgentProfile,registerParticipantProfile,registerChannelParticipant,readParticipantProfile,checkParticipantProfile,findParticipantProfiles,listIdentityProfiles,adoptLegacyAgentProfile,bindOwnAgent,bindRegisteredParticipant,redactIdentity,executeIdentity} from '../skills/ksf-workshop-runtime/scripts/ksf-identity.mjs';
+import {runWorkshop} from '../skills/ksf-workshop-runtime/scripts/ksf-workshop.mjs';
+import {registerBridgeIdentity} from '../skills/ksf-workshop-runtime/scripts/ksf-identity-bridge.mjs';
+const agentInput={name:'新数字人',role:'workshop',identityBasis:'operator_registration',runtimePlatform:'hermes',channel:'wecom',account:{platform:'wecom',tenantId:null,accountId:null,accountName:null,runtimeAgentId:null,wecomAppAgentId:null}};
+const employeeInput={name:'新员工',identityBasis:'participant_registration',account:{platform:'wecom',tenantId:null,accountId:null,accountName:null,employeeNumber:null}};
+function directory(t){const dir=fs.mkdtempSync(path.join(os.tmpdir(),'ksf-identity-test-'));t.after(()=>{assert.equal(path.dirname(dir),fs.realpathSync(os.tmpdir()));assert.ok(path.basename(dir).startsWith('ksf-identity-test-'));fs.rmSync(dir,{recursive:true});});return dir;}
+const dirs=root=>({profileDir:path.join(root,'agents'),participantDir:path.join(root,'participants')});
+test('新 Agent 自助登记生成通用稳定 UUID、同资料重用,密钥字段拒绝',t=>{
+ const root=directory(t),opts=dirs(root),a=registerAgentProfile(agentInput,opts),before=fs.readFileSync(path.join(opts.profileDir,a.profile.key.split(':')[1]+'.json'));
+ assert.match(a.profile.key,/^agent-registration:/);assert.equal(a.changed,true);assert.equal(registerAgentProfile({...agentInput,reuseKey:a.profile.key},opts).changed,false);assert.equal(registerAgentProfile(agentInput,opts).needsReview,true);assert.equal(readAgentProfileByKey(a.profile.key,opts.profileDir).name,'新数字人');
+ assert.equal(checkAgentProfile(a.profile.key,{...agentInput,collectedAt:'2026-11-02T02:00:00.000Z'},opts).matches,true);
+ assert.deepEqual(fs.readFileSync(path.join(opts.profileDir,a.profile.key.split(':')[1]+'.json')),before);
+ const duplicate=registerAgentProfile({...agentInput,name:'新数字人',account:{...agentInput.account,accountName:'其他账号'}},opts);assert.equal(duplicate.needsReview,true);assert.equal(duplicate.candidates.length,1);
+ assert.throws(()=>registerAgentProfile({...agentInput,account:{...agentInput.account,token:'fixture'}},opts),{code:'INVALID_IDENTITY_FIELDS'});
+ const separate=registerAgentProfile({...agentInput,confirmNew:true},opts);assert.equal(separate.changed,true);assert.notEqual(separate.profile.key,a.profile.key);
+});
+test('真实 Agent ID 生成可校验的账号摘要;稳定 ID 冲突拒绝',t=>{
+ const opts=dirs(directory(t)),input={...agentInput,identityBasis:'runtime_config',account:{...agentInput.account,accountId:'fixture-agent-account'}};
+ const p=registerAgentProfile(input,opts).profile;assert.match(p.key,/^agent-account:[a-f0-9]{64}$/);assert.equal(readAgentProfileByKey(p.key,opts.profileDir).key,p.key);
+ assert.throws(()=>registerAgentProfile({...input,name:'changed'},opts),{code:'AGENT_PROFILE_CONFLICT'});
+ assert.throws(()=>checkAgentProfile(p.key,{...input,runtimePlatform:'changed'},opts),{code:'AGENT_PROFILE_CONFLICT'});
+});
+
+test('可信来源必须有稳定 ID,篡改 UUID 档案的来源不能冒充运行配置',t=>{
+ const opts=dirs(directory(t));
+ for(const identityBasis of ['runtime_config','channel_context'])assert.throws(()=>registerAgentProfile({...agentInput,identityBasis},opts),{code:'RUNTIME_AGENT_ID_REQUIRED'});
+ for(const identityBasis of ['channel_context','existing_record'])assert.throws(()=>registerParticipantProfile({...employeeInput,identityBasis},opts),{code:'STABLE_EMPLOYEE_ID_REQUIRED'});
+ assert.throws(()=>registerParticipantProfile({...employeeInput,identityBasis:'channel_context',account:{...employeeInput.account,accountId:'user-without-corp'}},opts),{code:'STABLE_EMPLOYEE_ID_REQUIRED'});
+ const agent=registerAgentProfile(agentInput,opts).profile,file=path.join(opts.profileDir,agent.key.split(':')[1]+'.json'),row=JSON.parse(fs.readFileSync(file,'utf8'));
+ row.identityBasis='runtime_config';row.account.runtimeAgentId='actual-looking-id';fs.writeFileSync(file,JSON.stringify(row));
+ assert.throws(()=>readAgentProfileByKey(agent.key,opts.profileDir),{code:'INVALID_IDENTITY_FILE'});
+ const employee=registerParticipantProfile(employeeInput,opts).profile,employeeFile=path.join(opts.participantDir,employee.key.split(':')[1]+'.json'),person=JSON.parse(fs.readFileSync(employeeFile,'utf8'));
+ person.identityBasis='channel_context';person.account.tenantId='test-corp';person.account.accountId='test-user';fs.writeFileSync(employeeFile,JSON.stringify(person));
+ assert.throws(()=>readParticipantProfile(employee.key,opts.participantDir),{code:'INVALID_IDENTITY_FILE'});
+});
+
+test('任意数量同角色数字人可共存,旧档案迁入不覆盖原件,CLI仅输出摘要',t=>{
+ const root=directory(t),opts=dirs(root),first=registerAgentProfile(agentInput,opts).profile;
+ const second=registerAgentProfile({...agentInput,name:'另一个产品数字人'},opts).profile;assert.notEqual(first.key,second.key);
+ const legacy=path.join(root,'agent-self-workshop.json'),original=fs.readFileSync(path.join(opts.profileDir,first.key.split(':')[1]+'.json'));fs.writeFileSync(legacy,original,{mode:0o600});
+ const next={profileDir:path.join(root,'migrated')};assert.equal(adoptLegacyAgentProfile(legacy,next).changed,true);assert.equal(adoptLegacyAgentProfile(legacy,next).changed,false);assert.deepEqual(fs.readFileSync(legacy),original);
+ const input=path.join(root,'summary.json');fs.writeFileSync(input,JSON.stringify({...employeeInput,account:{...employeeInput.account,accountName:'private-account-name'}}));
+ const out=executeIdentity(['register-employee','--input',input],opts);assert.equal(out.databaseWrites,0);assert.equal(out.networkRequests,0);assert.equal(out.accountAuthenticated,false);assert.ok(!JSON.stringify(out).includes('private-account-name'));
+ const status=executeIdentity(['status'],opts);assert.equal(status.agents.length,2);assert.equal(status.participants.length,1);
+ assert.throws(()=>registerParticipantProfile({...employeeInput,reuseKey:out.profile.key,confirmNew:true},opts),{code:'INVALID_IDENTITY_VALUE'});
+ assert.throws(()=>readAgentProfileByKey('agent-registration:../../invalid',opts.profileDir),{code:'INVALID_AGENT_KEY'});
+});
+test('新员工先登记;稳定账号自动复用,无账号同名需人工确认',t=>{
+ const opts=dirs(directory(t)),first=registerParticipantProfile(employeeInput,opts);assert.equal(first.changed,true);assert.match(first.profile.key,/^participant-registration:/);
+ const candidates=findParticipantProfiles('新员工',opts);assert.equal(candidates.length,1);assert.ok(!JSON.stringify(candidates).includes('accountId'));
+ const pending=registerParticipantProfile(employeeInput,opts);assert.equal(pending.needsReview,true);assert.equal(pending.candidates[0].key,first.profile.key);
+ const reused=registerParticipantProfile({...employeeInput,reuseKey:first.profile.key},opts);assert.equal(reused.changed,false);assert.equal(reused.profile.key,first.profile.key);
+ const other=registerParticipantProfile({...employeeInput,confirmNew:true},opts);assert.notEqual(other.profile.key,first.profile.key);
+ const stable={...employeeInput,name:'有账号员工',identityBasis:'channel_context',account:{...employeeInput.account,tenantId:'corp-test',accountId:'wecom-user-7'}};
+ const a=registerParticipantProfile(stable,opts),b=registerParticipantProfile(stable,opts);assert.equal(a.profile.key,b.profile.key);assert.equal(b.changed,false);
+ assert.throws(()=>registerParticipantProfile({...stable,name:'不同显示名'},opts),{code:'PARTICIPANT_PROFILE_CONFLICT'});
+ assert.equal(checkParticipantProfile(first.profile.key,employeeInput,opts).matches,true);
+});
+test('workshop-start 必须引用登记的员工和 Agent 档案,并注入字段快照',async t=>{
+ const opts=dirs(directory(t)),agentRow=registerAgentProfile(agentInput,opts).profile,employeeRow=registerParticipantProfile(employeeInput,opts).profile;
+ const agent=readAgentProfileByKey(agentRow.key,opts.profileDir),participant=readParticipantProfile(employeeRow.key,opts.participantDir),calls=[];
+ const call=async(name,params)=>{calls.push({name,params});return name==='ksfGetIngestContract'?{workshop:{identitySchema:'ksf.agent-self.v1'}}:{sessionId:'session'};};
+ await assert.rejects(()=>runWorkshop('workshop-start',{participant:{profileKey:participant.key}},{requestId:'missing-agent',participantProfile:participant,call}),{code:'AGENT_PROFILE_REQUIRED'});
+ await assert.rejects(()=>runWorkshop('workshop-start',{}, {requestId:'missing-employee',agentProfile:agent,call}),{code:'PARTICIPANT_PROFILE_REQUIRED'});assert.equal(calls.length,0);
+ const input={workshopKey:'w',participant:{profileKey:participant.key},conversation:{key:'conversation-1',channel:'wecom'},groupId:'',dataMode:'sample'};
+ await runWorkshop('workshop-start',input,{requestId:'start',agentProfile:agent,participantProfile:participant,call});
+ const start=calls.find(x=>x.name==='ksfStartWorkshopSession').params;assert.deepEqual(start.agent,bindOwnAgent({},agent).agent);assert.deepEqual(start.participant,bindRegisteredParticipant({profileKey:participant.key},participant));assert.equal(start.dataMode,'sample');
+ calls.length=0;await assert.rejects(()=>runWorkshop('workshop-start',{...input,agent:{name:'别的数字人'}},{requestId:'fake',agentProfile:agent,participantProfile:participant,call}),{code:'AGENT_IDENTITY_MISMATCH'});assert.equal(calls.length,0);
+ await assert.rejects(()=>runWorkshop('workshop-start',input,{requestId:'ops',agentProfile:{...agent,role:'operations'},participantProfile:participant,call}),{code:'AGENT_ROLE_MISMATCH'});assert.equal(calls.length,0);
+});
+test('旧服务不支持身份版本时会话写入前停止',async t=>{
+ const opts=dirs(directory(t)),agentRow=registerAgentProfile(agentInput,opts).profile,employeeRow=registerParticipantProfile(employeeInput,opts).profile,calls=[];
+ const agent=readAgentProfileByKey(agentRow.key,opts.profileDir),employee=readParticipantProfile(employeeRow.key,opts.participantDir);
+ await assert.rejects(()=>runWorkshop('workshop-start',{participant:{profileKey:employee.key}},{requestId:'old-service',agentProfile:agent,participantProfile:employee,call:async name=>{calls.push(name);return{workshop:{version:'old'}};}}),{code:'IDENTITY_CAPABILITY_UNAVAILABLE'});assert.deepEqual(calls,['ksfGetIngestContract']);
+ await assert.rejects(()=>runWorkshop('workshop-start',{},{requestId:'no-agent',participantProfile:employee,call:async()=>{throw Error('must not call');}}),{code:'AGENT_PROFILE_REQUIRED'});
+});
+test('只读回读脱敏账号与会话键,公开producer不携带账号值',async t=>{
+ const opts=dirs(directory(t)),agentRow=registerAgentProfile({...agentInput,account:{...agentInput.account,accountId:'private-account'}},opts).profile,agent=readAgentProfileByKey(agentRow.key,opts.profileDir);
+ const result=await runWorkshop('workshop-read',{className:'KfArtifact',objectId:'artifact'},{requestId:'read',call:async(n,params)=>{assert.equal(n,'ksfGetRecord');assert.equal(params.objectId,'artifact');return{record:{title:'报告',agentName:agent.name,userRef:{objectId:'private-user'},meta:{ownerId:'private-service',attribution:{agentName:agent.name,agentAccount:{accountId:'private-account'},participantAccount:{accountId:'private-person'},conversationKey:'private-chat'}}}};}});
+ assert.equal(result.record.title,'报告');assert.ok(!JSON.stringify(result).includes('private-'));assert.deepEqual(redactIdentity({producer:{agent:{name:agent.name,key:agent.key,identityBasis:agent.identityBasis,account:{accountId:'private-account'}}}}).producer.agent,{name:agent.name,key:agent.key,identityBasis:agent.identityBasis});
+ assert.equal(listIdentityProfiles(opts).agents.length,1);
+});
+
+const channelContext={version:'ksf.wecom-aibot-context.v1',botId:'private-bot-one',senderId:'private-sender-one',chatType:'single',chatId:null,messageId:'message-one'};
+function channelAgent(opts,botId=channelContext.botId){const a=registerAgentProfile({...agentInput,name:botId==='private-bot-one'?'王刚001':'运维001',identityBasis:'runtime_config',account:{platform:'wecom',accountId:botId}},opts).profile;return readAgentProfileByKey(a.key,opts.profileDir);}
+
+test('aibot 无企业ID或姓名也能按发送者自动登记,单聊换群不改变员工键',t=>{
+ const opts=dirs(directory(t)),agentProfile=channelAgent(opts);
+ const first=registerChannelParticipant(channelContext,{...opts,agentProfile});assert.equal(first.changed,true);assert.equal(first.needsNameBinding,true);assert.match(first.profile.name,/^员工待绑定·/);
+ const row=readParticipantProfile(first.profile.key,opts.participantDir);assert.equal(row.account.tenantId,null);assert.equal(row.userId,null);assert.equal(row.account.accountId,channelContext.senderId);assert.equal(row.account.scopeId,channelContext.botId);assert.equal(row.account.identifierType,'wecom_aibot_sender');
+ const filename=path.join(opts.participantDir,row.key.split(':')[1]+'.json'),before=fs.readFileSync(filename);
+ const again=registerChannelParticipant({...channelContext,chatType:'group',chatId:'private-group',messageId:'message-two'},{...opts,agentProfile});assert.equal(again.changed,false);assert.equal(again.profile.key,first.profile.key);assert.deepEqual(fs.readFileSync(filename),before);
+ assert.equal(first.channelContextOriginVerified,false);assert.ok(!JSON.stringify(first).includes('private-'));
+});
+
+test('同一群里不同发送者分开;跨机器人标识不擅自合并',t=>{
+ const opts=dirs(directory(t)),agentProfile=channelAgent(opts),a=registerChannelParticipant({...channelContext,chatType:'group',chatId:'private-group'},{...opts,agentProfile});
+ const b=registerChannelParticipant({...channelContext,senderId:'private-sender-two',chatType:'group',chatId:'private-group'},{...opts,agentProfile});assert.notEqual(a.profile.key,b.profile.key);
+ const other=channelAgent(opts,'private-bot-two'),c=registerChannelParticipant({...channelContext,botId:'private-bot-two'},{...opts,agentProfile:other});assert.notEqual(a.profile.key,c.profile.key);assert.equal(listIdentityProfiles(opts).participants.length,3);
+});
+
+test('aibot 缺发送者/机器人/群会话ID或机器人不匹配在登记前停止',t=>{
+ const opts=dirs(directory(t)),agentProfile=channelAgent(opts);
+ for(const bad of [{...channelContext,senderId:''},{...channelContext,botId:''},{...channelContext,chatType:'group',chatId:null},{...channelContext,displayName:'猜出的姓名'},{...channelContext,rawText:'不得从正文提取身份'}])assert.throws(()=>registerChannelParticipant(bad,{...opts,agentProfile}));
+ assert.throws(()=>registerChannelParticipant({...channelContext,botId:'different-bot'},{...opts,agentProfile}),{code:'CHANNEL_AGENT_MISMATCH'});
+ assert.equal(listIdentityProfiles(opts).participants.length,0);
+});
+
+test('aibot 旧文件 CLI 已关闭;旧合同与错机器人阻止会话',async t=>{
+ const root=directory(t),opts=dirs(root),agentProfile=channelAgent(opts),file=path.join(root,'inbound.json');fs.writeFileSync(file,JSON.stringify(channelContext),{mode:0o600});
+ for(const argv of [[],['--context',file],['--agent-profile',agentProfile.key,'--context',file],['--message-id','made-up']])assert.throws(()=>executeIdentity(['register-channel-employee',...argv],opts),{code:'TRUSTED_IDENTITY_TOOL_REQUIRED'});
+ assert.equal(listIdentityProfiles(opts).participants.length,0);
+ const result=registerBridgeIdentity({version:'ksf.current-employee-registration.v1',agentProfileKey:agentProfile.key,identity:channelContext},opts);assert.ok(!JSON.stringify(result).includes('private-'));assert.equal(result.databaseWrites,0);assert.equal(result.accountAuthenticated,false);
+ const participantProfile=readParticipantProfile(result.profile.key,opts.participantDir),task={participant:{profileKey:participantProfile.key},conversation:{key:'private-chat',channel:'wecom'}},calls=[];
+ const options={requestId:'channel-start',agentProfile,participantProfile,call:async name=>{calls.push(name);return{workshop:{identitySchema:'ksf.agent-self.v1'}};}};
+ await assert.rejects(()=>runWorkshop('workshop-start',task,options),{code:'CHANNEL_IDENTITY_CAPABILITY_UNAVAILABLE'});assert.deepEqual(calls,['ksfGetIngestContract']);
+ const call=async name=>{calls.push(name);return{workshop:{identitySchema:'ksf.agent-self.v1',channelIdentitySchema:'ksf.wecom-aibot-sender.v1'}};};
+ calls.length=0;await assert.rejects(()=>runWorkshop('workshop-start',task,{...options,call,agentProfile:channelAgent(opts,'private-bot-two')}),{code:'CHANNEL_AGENT_MISMATCH'});assert.deepEqual(calls,['ksfGetIngestContract']);
+ assert.deepEqual(redactIdentity({scopeId:'private-bot',botId:'private-bot',senderId:'private-user',name:'显示标签'}),{name:'显示标签'});
+});
+
+test('通用员工 JSON 不能替代通道工具;内部登记复用规范键且拒绝错实例与运维角色',t=>{
+ const root=directory(t),opts=dirs(root),agentProfile=channelAgent(opts),input=path.join(root,'input.json');
+ for(const value of [
+  {...employeeInput,identityBasis:'channel_context',account:{platform:'wecom',tenantId:'corp',accountId:'sender'}},
+  {...employeeInput,account:{platform:'wecom',identifierType:'wecom_aibot_sender',scopeId:'bot',accountId:'sender'}}
+ ]){fs.writeFileSync(input,JSON.stringify(value));assert.throws(()=>executeIdentity(['register-employee','--input',input],opts),{code:'TRUSTED_IDENTITY_TOOL_REQUIRED'});}
+ assert.equal(listIdentityProfiles(opts).participants.length,0);
+ const request={version:'ksf.current-employee-registration.v1',agentProfileKey:agentProfile.key,identity:channelContext};
+ const a=registerBridgeIdentity(request,opts),b=registerBridgeIdentity({...request,identity:{...channelContext,messageId:'another-message',chatType:'group',chatId:'group'}},opts);
+ assert.equal(a.profile.key,b.profile.key);assert.equal(a.changed,true);assert.equal(b.changed,false);
+ assert.throws(()=>registerBridgeIdentity({...request,messageId:'extra-field'},opts),{code:'INVALID_BRIDGE_REQUEST'});
+ assert.throws(()=>registerBridgeIdentity({...request,identity:{...channelContext,botId:'other-bot'}},opts),{code:'CHANNEL_AGENT_MISMATCH'});
+ const ops=registerAgentProfile({...agentInput,name:'运维001',role:'operations',account:{platform:'wecom',accountId:'operations-bot'}},opts).profile;
+ assert.throws(()=>registerBridgeIdentity({...request,agentProfileKey:ops.key},opts),{code:'AGENT_ROLE_MISMATCH'});
+ assert.equal(listIdentityProfiles(opts).participants.length,1);
+});
+
+test('上传实名与待同步回执均保留;成功保存不因实名状态再次调用',async t=>{
+ const opts=dirs(directory(t)),agentProfile=channelAgent(opts);
+ const verified={employee:{key:'employee:'+'a'.repeat(64),name:'测试员工',departmentNames:['测试产品部'],source:'wecom_directory',nameVerified:true,verifiedAt:'2026-11-03T00:00:00.000Z'},employeeIdentity:{automatic:true,status:'verified'}};
+ const pending={employee:{key:'channel-employee:'+'b'.repeat(64),name:null,source:'wecom_sender',nameVerified:false},employeeIdentity:{automatic:true,status:'pending',reason:'WECOM_DIRECTORY_NOT_CONFIGURED'}};
+ for(const receipt of [verified,pending,{}]){
+  const calls=[];
+  const result=await runWorkshop('workshop-save',{sessionId:'original-session',taskKey:'original-task',version:1},{requestId:'original-save',agentProfile,call:async(name,params)=>{
+   calls.push({name,params});
+   if(name==='ksfGetRecord')return{record:{meta:{attribution:{agentKey:agentProfile.key,agentName:agentProfile.name,agentRuntimePlatform:agentProfile.runtimePlatform,agentChannel:agentProfile.channel}}}};
+   assert.equal(name,'ksfSaveWorkshopTask');assert.equal(params.requestId,'original-save');
+   assert.equal(params.sessionId,'original-session');assert.equal(params.taskKey,'original-task');
+   return{artifactId:'saved-artifact',...receipt,meta:{attribution:{participantAccount:{accountId:'private-sender'},agentAccount:{accountId:'private-bot'}}}};
+  }});
+  assert.equal(result.artifactId,'saved-artifact');assert.deepEqual(result.employee,receipt.employee);assert.deepEqual(result.employeeIdentity,receipt.employeeIdentity);
+  assert.deepEqual(calls.map(x=>x.name),['ksfGetRecord','ksfSaveWorkshopTask']);assert.ok(!JSON.stringify(result).includes('private-'));
+ }
+});
+
+test('通讯录查询失败透传并停止,Skill运行库不换键重试保存',async t=>{
+ const opts=dirs(directory(t)),agentProfile=channelAgent(opts),calls=[];
+ await assert.rejects(()=>runWorkshop('workshop-save',{sessionId:'original-session',taskKey:'original-task'},{requestId:'original-save',agentProfile,call:async(name,params)=>{
+  calls.push({name,params});
+  if(name==='ksfGetRecord')return{record:{meta:{attribution:{agentKey:agentProfile.key,agentName:agentProfile.name,agentRuntimePlatform:agentProfile.runtimePlatform,agentChannel:agentProfile.channel}}}};
+  throw Object.assign(Error('fixture failure'),{code:'WECOM_SENDER_MAPPING_REQUIRED'});
+ }}),{code:'WECOM_SENDER_MAPPING_REQUIRED'});
+ assert.deepEqual(calls.map(x=>x.name),['ksfGetRecord','ksfSaveWorkshopTask']);assert.equal(calls[1].params.requestId,'original-save');
+});

+ 54 - 0
test/workshop-skill-install.test.js

@@ -0,0 +1,54 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import fs from 'node:fs';
+import os from 'node:os';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { installWorkshopSkill } from '../scripts/install-workshop-skill.mjs';
+
+const source = fileURLToPath(new URL('../skills/ksf-workshop-runtime', import.meta.url));
+function fixture(t) {
+  const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ksf-skill-install-'));
+  t.after(() => {
+    assert.equal(path.dirname(root), fs.realpathSync(os.tmpdir()));
+    assert.ok(path.basename(root).startsWith('ksf-skill-install-'));
+    fs.rmSync(root, { recursive: true });
+  });
+  const destination = path.join(root, 'skills', 'ksf-workshop-runtime');
+  fs.cpSync(source, destination, { recursive: true });
+  fs.writeFileSync(path.join(destination, 'credential-binding.json'), 'fixture-private-binding');
+  const state = path.join(root, 'ksf-workshop-state', 'participants');
+  fs.mkdirSync(state, { recursive: true });
+  fs.writeFileSync(path.join(state, 'fixture.json'), 'fixture-private-state');
+  return { root, destination, state };
+}
+
+test('Skill只读预检与重复安装不改变绑定、状态和文件,也不创建备份', t => {
+  const f = fixture(t);
+  const check = installWorkshopSkill(source, f.destination, { apply: false });
+  assert.equal(check.readOnly, true); assert.equal(check.filesChecked, 11); assert.deepEqual(check.changed, []);
+  const result = installWorkshopSkill(source, f.destination);
+  assert.deepEqual(result.changed, []); assert.equal(result.backup, null);
+  assert.equal(fs.readFileSync(path.join(f.destination, 'credential-binding.json'), 'utf8'), 'fixture-private-binding');
+  assert.equal(fs.readFileSync(path.join(f.state, 'fixture.json'), 'utf8'), 'fixture-private-state');
+  assert.ok(!JSON.stringify(result).includes('fixture-private'));
+});
+
+test('未知目标修改在升级前停止,不覆盖维护人的Skill或清单', t => {
+  for (const name of ['SKILL.md', 'FILES.sha256']) {
+    const f = fixture(t), target = path.join(f.destination, name);
+    fs.writeFileSync(target, 'maintainer-concurrent-edit');
+    assert.throws(() => installWorkshopSkill(source, f.destination), { code: name === 'SKILL.md' ? 'TARGET_HAS_UNEXPECTED_CONTENT' : 'TARGET_MANIFEST_HAS_UNEXPECTED_CONTENT' });
+    assert.equal(fs.readFileSync(target, 'utf8'), 'maintainer-concurrent-edit');
+    assert.deepEqual(fs.readdirSync(f.root).sort(), ['ksf-workshop-state', 'skills']);
+  }
+});
+
+test('包内脚本被篡改时拒绝升级且保留已有文件', t => {
+  const f = fixture(t), altered = path.join(f.root, 'altered-package');
+  fs.cpSync(source, altered, { recursive: true });
+  fs.appendFileSync(path.join(altered, 'scripts', 'ksf-workshop.mjs'), '\n// tampered\n');
+  const before = fs.readFileSync(path.join(f.destination, 'SKILL.md'));
+  assert.throws(() => installWorkshopSkill(altered, f.destination), { code: 'PACKAGE_HASH_MISMATCH' });
+  assert.deepEqual(fs.readFileSync(path.join(f.destination, 'SKILL.md')), before);
+});