@givolt/i3-composer-agent 2.3.1 → 2.3.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (173) hide show
  1. package/.agents/plugins/marketplace.json +1 -1
  2. package/.claude/commands/i3-prototype.md +5 -7
  3. package/.claude-plugin/marketplace.json +1 -1
  4. package/.claude-plugin/plugin.json +1 -1
  5. package/.codex-plugin/plugin.json +1 -1
  6. package/.gemini/commands/i3-prototype.toml +5 -7
  7. package/README.md +16 -7
  8. package/bin/agent-targets.json +4 -2
  9. package/bin/i3-composer-agent.cjs +74 -7
  10. package/bin/install.cjs +16 -1
  11. package/bin/native-layout.cjs +1 -1
  12. package/bin/native-migration.cjs +27 -3
  13. package/bin/tool-selector.cjs +24 -7
  14. package/bin/wizard.cjs +89 -15
  15. package/commands/i3-prototype.toml +5 -7
  16. package/docs/codebuddy-setup.md +91 -3
  17. package/docs/codex-setup.md +6 -0
  18. package/docs/other-hosts.md +1 -1
  19. package/docs/ui-prototype-native/MAIN-SESSION-REPORT.md +66 -0
  20. package/docs/ui-prototype-native/USAGE.md +93 -0
  21. package/docs/workbuddy-setup.md +6 -0
  22. package/evals/cases/i3-frontend-ui-engineering.json +16 -0
  23. package/evals/cases/i3-ui-prototype.json +109 -0
  24. package/evals/cases/i3-using-agent-skills.json +28 -0
  25. package/evals/fixtures/i3-ui-prototype/admin-list/assets/reference.css +3 -0
  26. package/evals/fixtures/i3-ui-prototype/admin-list/assets/reference.js +8 -0
  27. package/evals/fixtures/i3-ui-prototype/admin-list/capability-definition.json +67 -0
  28. package/evals/fixtures/i3-ui-prototype/admin-list/index.html +89 -0
  29. package/evals/fixtures/i3-ui-prototype/admin-list/readme.md +52 -0
  30. package/evals/fixtures/i3-ui-prototype/admin-list/template.json +8 -0
  31. package/evals/zh-CN/cases/i3-frontend-ui-engineering.json +17 -0
  32. package/evals/zh-CN/cases/i3-ui-prototype.json +115 -0
  33. package/evals/zh-CN/cases/i3-using-agent-skills.json +30 -0
  34. package/hooks/codebuddy-upstream.cjs +93 -0
  35. package/hooks/i3-prototype.cjs +12 -0
  36. package/hooks/manage.cjs +162 -0
  37. package/package.json +61 -1
  38. package/plugin.json +1 -1
  39. package/scripts/design-provider-test.js +7 -0
  40. package/scripts/enterprise-template-test.js +27 -1
  41. package/scripts/lib/prototype-fixture.cjs +17 -0
  42. package/scripts/prototype-cache-test.js +55 -0
  43. package/scripts/prototype-generation-entry-test.js +6 -0
  44. package/scripts/prototype-generation-evidence-test.js +10 -0
  45. package/scripts/prototype-generation-native-test.js +78 -0
  46. package/scripts/prototype-generation-test.js +99 -0
  47. package/scripts/prototype-inventory-test.js +43 -0
  48. package/scripts/prototype-locations-test.js +79 -0
  49. package/scripts/prototype-project-test.js +183 -0
  50. package/scripts/ui-prototype-codebuddy-hook-test.js +106 -0
  51. package/scripts/ui-prototype-content-test.js +123 -0
  52. package/scripts/ui-prototype-dependencies-test.js +213 -0
  53. package/scripts/ui-prototype-devtools-test.js +53 -0
  54. package/scripts/ui-prototype-execution-evidence-test.js +62 -0
  55. package/scripts/ui-prototype-execution-gates-test.js +66 -0
  56. package/scripts/ui-prototype-execution-policy-test.js +42 -0
  57. package/scripts/ui-prototype-execution-runtime-test.js +27 -0
  58. package/scripts/ui-prototype-ide-source-test.js +73 -0
  59. package/scripts/ui-prototype-native-test.js +504 -0
  60. package/scripts/ui-prototype-registration-test.js +53 -0
  61. package/scripts/ui-prototype-template-test.js +261 -0
  62. package/scripts/ui-prototype-test.js +10 -0
  63. package/scripts/ui-prototype-transcript-auto-test.js +67 -0
  64. package/skills/i3-frontend-ui-engineering/SKILL.md +1 -1
  65. package/skills/i3-incremental-implementation/SKILL.md +1 -1
  66. package/skills/i3-ui-prototype/SKILL.md +158 -19
  67. package/skills/i3-ui-prototype/assets/capabilities/generation.json +833 -0
  68. package/skills/i3-ui-prototype/assets/capabilities/management.json +294 -0
  69. package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/acceptance.json +51 -0
  70. package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/component-map.json +13 -0
  71. package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/generation-component-map.json +300 -0
  72. package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/legacy-starter/index.html +11 -0
  73. package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/legacy-starter/package-lock.json +2298 -0
  74. package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/legacy-starter/package.json +22 -0
  75. package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/legacy-starter/public/prototype-config.json +6 -0
  76. package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/legacy-starter/src/App.vue +136 -0
  77. package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/legacy-starter/src/components/DeviceEditor.vue +111 -0
  78. package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/legacy-starter/src/config.ts +19 -0
  79. package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/legacy-starter/src/content.ts +28 -0
  80. package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/legacy-starter/src/domain/devices.ts +50 -0
  81. package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/legacy-starter/src/domain/theme.ts +25 -0
  82. package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/legacy-starter/src/main.ts +18 -0
  83. package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/legacy-starter/src/style.css +50 -0
  84. package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/legacy-starter/src/useDevices.ts +66 -0
  85. package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/legacy-starter/tests/devices.test.ts +44 -0
  86. package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/legacy-starter/tests/theme.test.ts +26 -0
  87. package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/legacy-starter/tsconfig.json +15 -0
  88. package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/legacy-starter/vite.config.ts +4 -0
  89. package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/profile.json +174 -0
  90. package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/readme.md +61 -0
  91. package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/starter/index.html +11 -0
  92. package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/starter/package-lock.json +2298 -0
  93. package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/starter/package.json +22 -0
  94. package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/starter/public/prototype-config.json +21 -0
  95. package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/starter/src/App.vue +62 -0
  96. package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/starter/src/PrototypeShell.vue +59 -0
  97. package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/starter/src/config.ts +23 -0
  98. package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/starter/src/domain/presentation.ts +29 -0
  99. package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/starter/src/domain/theme.ts +25 -0
  100. package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/starter/src/main.ts +18 -0
  101. package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/starter/src/style.css +26 -0
  102. package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/starter/tests/presentation.test.ts +23 -0
  103. package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/starter/tests/theme.test.ts +26 -0
  104. package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/starter/tsconfig.json +15 -0
  105. package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/starter/vite.config.ts +4 -0
  106. package/skills/i3-ui-prototype/assets/hosts/codebuddy/profile.json +13 -0
  107. package/skills/i3-ui-prototype/assets/optional-skills/host-targets.json +739 -0
  108. package/skills/i3-ui-prototype/assets/optional-skills/sources.json +27 -0
  109. package/skills/i3-ui-prototype/assets/templates/catalog.js +149 -0
  110. package/skills/i3-ui-prototype/assets/templates/catalog.json +2 -1
  111. package/skills/i3-ui-prototype/assets/templates/enterprise-admin/capability-definition.json +294 -0
  112. package/skills/i3-ui-prototype/assets/templates/enterprise-admin/component-inventory.json +707 -0
  113. package/skills/i3-ui-prototype/assets/templates/enterprise-admin/generation.json +49 -0
  114. package/skills/i3-ui-prototype/assets/templates/enterprise-admin/index.html +507 -2
  115. package/skills/i3-ui-prototype/assets/templates/enterprise-admin/offline-build.json +265 -0
  116. package/skills/i3-ui-prototype/assets/templates/enterprise-admin/offline.js +8888 -0
  117. package/skills/i3-ui-prototype/assets/templates/enterprise-admin/template-inventory.json +28791 -0
  118. package/skills/i3-ui-prototype/assets/templates/enterprise-admin/template.json +9 -0
  119. package/skills/i3-ui-prototype/assets/templates/gallery/index.html +5 -5
  120. package/skills/i3-ui-prototype/assets/templates/generation-options.js +42 -0
  121. package/skills/i3-ui-prototype/assets/templates/index.html +3 -0
  122. package/skills/i3-ui-prototype/assets/templates/presentation.html +17 -0
  123. package/skills/i3-ui-prototype/references/artifact-contract.md +26 -1
  124. package/skills/i3-ui-prototype/references/content-template.md +81 -0
  125. package/skills/i3-ui-prototype/references/enterprise-admin.md +6 -0
  126. package/skills/i3-ui-prototype/references/entry-contract.md +71 -0
  127. package/skills/i3-ui-prototype/references/execution-evidence.md +66 -0
  128. package/skills/i3-ui-prototype/references/expert-adapter.md +33 -5
  129. package/skills/i3-ui-prototype/references/framework-contract.md +76 -0
  130. package/skills/i3-ui-prototype/references/generation-contract.md +122 -0
  131. package/skills/i3-ui-prototype/references/interaction-acceptance.md +169 -0
  132. package/skills/i3-ui-prototype/references/legacy-native.md +58 -0
  133. package/skills/i3-ui-prototype/references/optional-skills.md +63 -0
  134. package/skills/i3-ui-prototype/references/project-layout.md +92 -0
  135. package/skills/i3-ui-prototype/references/template-adapters.md +7 -1
  136. package/skills/i3-ui-prototype/references/workflow-contract.md +62 -4
  137. package/skills/i3-ui-prototype/scripts/adapters/easel-html.cjs +1 -1
  138. package/skills/i3-ui-prototype/scripts/browser-workspace.cjs +72 -0
  139. package/skills/i3-ui-prototype/scripts/codebuddy-context.cjs +75 -0
  140. package/skills/i3-ui-prototype/scripts/codebuddy-hook.cjs +157 -0
  141. package/skills/i3-ui-prototype/scripts/codebuddy-ide-source.cjs +69 -0
  142. package/skills/i3-ui-prototype/scripts/codebuddy-registration.cjs +123 -0
  143. package/skills/i3-ui-prototype/scripts/content-template.cjs +98 -0
  144. package/skills/i3-ui-prototype/scripts/enterprise-template.cjs +13 -6
  145. package/skills/i3-ui-prototype/scripts/execution-assertions.cjs +43 -0
  146. package/skills/i3-ui-prototype/scripts/execution-evidence.cjs +146 -0
  147. package/skills/i3-ui-prototype/scripts/execution-gates.cjs +147 -0
  148. package/skills/i3-ui-prototype/scripts/execution-policy.cjs +41 -0
  149. package/skills/i3-ui-prototype/scripts/execution-runtime.cjs +60 -0
  150. package/skills/i3-ui-prototype/scripts/generation-assets.cjs +18 -0
  151. package/skills/i3-ui-prototype/scripts/generation-contract.cjs +214 -0
  152. package/skills/i3-ui-prototype/scripts/generation-evidence.cjs +50 -0
  153. package/skills/i3-ui-prototype/scripts/generation-replan.cjs +34 -0
  154. package/skills/i3-ui-prototype/scripts/hosts/codebuddy.cjs +16 -0
  155. package/skills/i3-ui-prototype/scripts/manage-baseline.cjs +1 -1
  156. package/skills/i3-ui-prototype/scripts/native-browser.cjs +785 -0
  157. package/skills/i3-ui-prototype/scripts/native-build.cjs +91 -0
  158. package/skills/i3-ui-prototype/scripts/native-delivery.cjs +103 -0
  159. package/skills/i3-ui-prototype/scripts/native-devtools.cjs +92 -0
  160. package/skills/i3-ui-prototype/scripts/native-files.cjs +35 -0
  161. package/skills/i3-ui-prototype/scripts/native-pilot-lib.cjs +292 -0
  162. package/skills/i3-ui-prototype/scripts/native-pilot.cjs +50 -0
  163. package/skills/i3-ui-prototype/scripts/optional-skill-install.cjs +203 -0
  164. package/skills/i3-ui-prototype/scripts/project-registry.cjs +126 -0
  165. package/skills/i3-ui-prototype/scripts/prototype-dependencies.cjs +81 -0
  166. package/skills/i3-ui-prototype/scripts/prototype-hooks.cjs +87 -0
  167. package/skills/i3-ui-prototype/scripts/prototype-lib.cjs +31 -13
  168. package/skills/i3-ui-prototype/scripts/prototype-locations.cjs +69 -0
  169. package/skills/i3-ui-prototype/scripts/sync-template.cjs +19 -0
  170. package/skills/i3-ui-prototype/scripts/template-contract.cjs +407 -0
  171. package/skills/i3-ui-prototype/scripts/template-inventory.cjs +78 -0
  172. package/skills/i3-ui-prototype/scripts/validate-result.cjs +117 -0
  173. package/skills/i3-using-agent-skills/SKILL.md +2 -5
@@ -0,0 +1,106 @@
1
+ 'use strict';
2
+ const prototypeScripts=(()=>{const fs=require('node:fs'),path=require('node:path'),file=path.join(__dirname,'lib/installation.js');return path.join(fs.existsSync(file)?require(file).loadInstallation().skillsDirectory:path.resolve(__dirname,'../skills'),'i3-ui-prototype/scripts');})();
3
+ const test=require('node:test'),assert=require('node:assert/strict');
4
+ const H=require(prototypeScripts+'/codebuddy-hook.cjs'),E=require(prototypeScripts+'/execution-evidence.cjs');
5
+ test('native command parsing handles quoted paths without evaluating shell substitutions or chains',()=>{
6
+ const c=H.nativeCommand('Bash',{command:"'/node path/node' '/skills path/native-pilot.cjs' execution-begin '/work/prototypes/a b'"},'/work');
7
+ assert.equal(c.root,'/work/prototypes/a b');assert.equal(c.operation,'execution-begin');
8
+ for(const command of ['node x; node native-pilot.cjs finalize /x','$(steal) native-pilot.cjs build /x','node x | node native-pilot.cjs finalize /x'])assert.equal(H.nativeCommand('Bash',{command},'/work'),null);
9
+ });
10
+ test('before UI write requires a successful frontend Skill in the same live context and task',()=>{
11
+ const source={prompts:[{line:1,text:'create prototype'}],calls:[],contextLine:0},binding={entryLine:1,contextLine:0,sessionId:'s1'};
12
+ const input={event:'PreToolUse',source,binding,tool:'Write',input:{file_path:'/work/prototypes/p/app/src/A.vue'},directory:'/work/prototypes/p'};
13
+ assert.equal(H.decision(input).deny,true);
14
+ source.calls=[{skill:'frontend',sessionId:'s1',requestLine:2,resultLine:3,success:true}];assert.equal(H.decision(input).continue,true);
15
+ source.contextLine=4;assert.equal(H.decision(input).deny,true);
16
+ });
17
+ test('analysis overrides old tasks and Stop feedback is bounded without granting tool permissions',()=>{
18
+ const source={prompts:[{line:1,text:'先分析,不改代码'}],calls:[],contextLine:0};
19
+ assert.equal(H.decision({event:'Stop',source,binding:{},deliveryCurrent:false}).feedback,undefined);
20
+ source.prompts[0].text='implement prototype';
21
+ source.latestAssistant='已完成,可以交付';
22
+ assert.equal(H.decision({event:'Stop',source,binding:{},deliveryCurrent:false}).feedback,true);
23
+ assert.equal(H.decision({event:'Stop',source,binding:{},deliveryCurrent:false,stopHookActive:true}).continue,true);
24
+ assert.equal(H.decision({event:'Stop',source,binding:{},deliveryCurrent:true}).continue,true);
25
+ assert.equal(JSON.stringify(H.decision({event:'PreToolUse',source,tool:'Read',directory:'/p'})).includes('permissionDecision'),false);
26
+ });
27
+ test('native compact boundaries invalidate loads and mixed-session results are rejected',()=>{
28
+ const rows=[{type:'user',sessionId:'s1',message:{content:'create prototype'}},{type:'assistant',sessionId:'s1',message:{content:[{type:'tool_use',id:'f',name:'Skill',input:{skill:'i3-frontend-ui-engineering'}}]}},{type:'user',sessionId:'s2',message:{content:[{type:'tool_result',tool_use_id:'f',content:'loaded'}]}}];
29
+ assert.throws(()=>E.parse(rows.map(r=>JSON.stringify(r)).join('\n')),/session/);
30
+ rows[2].sessionId='s1';rows.push({type:'system',subtype:'compact_boundary'});
31
+ assert.equal(E.describe(rows.map(r=>JSON.stringify(r)).join('\n')).contextLine,4);
32
+ });
33
+ test('effective binding differs by IDE override and CLI merge without writing any settings',()=>{
34
+ const C=require(prototypeScripts+'/codebuddy-context.cjs'),path=require('node:path');
35
+ const texts=new Map(),records=new Map(),project='/work/p',home='/home/user',projectBase=path.join(project,'.codebuddy'),globalBase=path.join(home,'.codebuddy');
36
+ const args={project,home,readText:file=>texts.get(file)||null,loadRegistration:base=>records.get(base)||null};
37
+ records.set(globalBase,{base:globalBase,product:'ide'});texts.set(path.join(projectBase,'settings.json'),'{}');
38
+ assert.equal(C.select({...args,product:'ide'}),null,'IDE cannot fall back through shadowing project settings');
39
+ records.set(globalBase,{base:globalBase,product:'cli'});assert.equal(C.select({...args,product:'cli'}).base,globalBase);
40
+ records.set(projectBase,{base:projectBase,product:'cli'});assert.equal(C.select({...args,product:'cli'}).base,projectBase);
41
+ texts.set(path.join(projectBase,'settings.local.json'),'{"hooks":{}}');assert.throws(()=>C.select({...args,product:'cli'}),/overrides/);
42
+ assert.notEqual(C.projectKey('/work/p'),C.projectKey('/other/p'));
43
+ });
44
+ test('read-only intent does not confuse business pause buttons with cancelling implementation',()=>{
45
+ const C=require(prototypeScripts+'/codebuddy-context.cjs');
46
+ assert.equal(C.readOnly('先分析,不改代码'),true);assert.equal(C.readOnly('先检查再实现原型'),false);
47
+ assert.equal(C.readOnly('增加暂停按钮,不改业务逻辑'),false);assert.equal(C.readOnly('暂停当前修改'),true);
48
+ });
49
+ test('a Hook payload must resolve to the actual current native request rather than a model-written begin',()=>{
50
+ const source={prompts:[{line:1,text:'create prototype'}],calls:[{name:'Bash',toolCallId:'native-begin',input:{command:'node native-pilot.cjs execution-begin /p'},requestLine:3}]};
51
+ const payload={hook_event_name:'PreToolUse',tool_name:'Bash',tool_input:{command:'node native-pilot.cjs execution-begin /p'}};
52
+ assert.equal(H.eventCall(payload,source).toolCallId,'native-begin');
53
+ assert.throws(()=>H.eventCall({...payload,tool_input:{command:'fabricated begin'}},source),/uniquely/);
54
+ source.calls[0].resultLine=4;assert.throws(()=>H.eventCall(payload,source),/uniquely/);
55
+ });
56
+ test('project-scope callbacks use their registered root when tools run in a subdirectory',()=>{
57
+ const payload={cwd:'/business/p/src'},real=p=>p;
58
+ assert.equal(H.safeProject(payload,{scope:'project',base:'/business/p/.codebuddy'},{},real),'/business/p');
59
+ assert.equal(H.safeProject(payload,{scope:'global',base:'/custom/codebuddy'},{CODEBUDDY_PROJECT_DIR:'/business/p'},real),'/business/p');
60
+ assert.equal(H.safeProject({hook_event_name:'SessionStart'},{scope:'global',base:'/custom/codebuddy'},{},real,'/business/p'),'/business/p');
61
+ assert.throws(()=>H.safeProject({cwd:'/other'},{scope:'project',base:'/business/p/.codebuddy'},{},real),/outside/);
62
+ });
63
+ test('the frontend gate blocks project-relative native Write calls before any run binding',()=>{
64
+ const result=H.decision({event:'PreToolUse',source:{prompts:[{line:1,text:'实现原型'}],calls:[],contextLine:0},tool:'Write',input:{file_path:'prototypes/p/app/src/Probe.vue'},directory:'/work/prototypes/p',cwd:'/work'});
65
+ assert.equal(result.deny,true);assert.match(result.reason,/frontend/);
66
+ const ide=H.decision({event:'PreToolUse',source:{prompts:[{line:1,text:'实现原型'}],calls:[],contextLine:0},tool:'Write',input:{filePath:'/work/prototypes/p/app/src/Probe.vue'},directory:'/work/prototypes/p',cwd:'/work'});
67
+ assert.equal(ide.deny,true);
68
+ });
69
+ test('IDE streaming requests correlate only within the exact native generation and unique call',()=>{
70
+ const source={format:'codebuddy-ide-index-v1',prompts:[{line:1}],calls:[{name:'Write',toolCallId:'actual-ide-call',nativeRequestId:'generation-1',input:{content:'<template>'},requestLine:2}]};
71
+ const payload={hook_event_name:'PreToolUse',client:'CodeBuddyIDE',generation_id:'generation-1',tool_name:'Write',tool_input:{filePath:'/work/p/app/src/A.vue',content:'<template><p>A</p></template>'}};
72
+ assert.equal(H.eventCall(payload,source).toolCallId,'actual-ide-call');
73
+ assert.throws(()=>H.eventCall({...payload,generation_id:'other'},source),/uniquely/);
74
+ assert.throws(()=>H.eventCall({...payload,client:'other'},source),/uniquely/);
75
+ assert.throws(()=>H.eventCall({...payload,tool_input:{content:'unrelated'}},source),/uniquely/);
76
+ source.calls.push({...source.calls[0],toolCallId:'second-call'});assert.throws(()=>H.eventCall(payload,source),/uniquely/);
77
+ });
78
+ test('IDE transient reads retry while wrong generations and mismatched pending requests stay rejected',()=>{
79
+ const payload={hook_event_name:'PreToolUse',client:'CodeBuddyIDE',generation_id:'g1',tool_name:'Write',tool_input:{content:'ui'}};
80
+ const pending={format:'codebuddy-ide-index-v1',prompts:[{line:1,nativeRequestId:'g1'}],calls:[{name:'Write',toolCallId:'w1',nativeRequestId:'g1',input:{content:'ui'},requestLine:2}]};
81
+ let reads=0,waits=0;
82
+ const readSource=()=>{if(++reads<3)throw new Error('native index write in progress');return pending;};
83
+ const result=H.eventSource(payload,{product:'ide'},'/work',{readSource,pause:ms=>{assert.equal(ms,100);waits++;}});
84
+ assert.equal(result.calls[0].toolCallId,'w1');assert.equal(result.nativeReadAttempts,3);assert.equal(waits,2);
85
+ reads=0;assert.throws(()=>H.eventSource({...payload,generation_id:'g2'},{product:'ide'},'/work',{readSource:()=>{reads++;return pending;},pause:()=>{}}),/generation/);assert.equal(reads,8);
86
+ assert.throws(()=>H.eventSource({...payload,tool_input:{content:'different'}},{product:'ide'},'/work',{readSource:()=>pending,pause:()=>{}}),/uniquely/);
87
+ reads=0;assert.throws(()=>H.eventSource({...payload,client:'cli'},{product:'cli'},'/work',{readSource:()=>{reads++;return {...pending,calls:[]};},pause:()=>assert.fail('CLI must not retry')}),/uniquely/);assert.equal(reads,1);
88
+ });
89
+ test('IDE defers current callback evidence until the host persists it and never invents Skill success',()=>{
90
+ const source={format:'codebuddy-ide-index-v1',prompts:[{line:1,nativeRequestId:'g1',text:'implement prototype'}],calls:[],contextLine:0};
91
+ const payload={hook_event_name:'PreToolUse',client:'CodeBuddyIDE',generation_id:'g1',tool_name:'Write',tool_input:{filePath:'/work/prototypes/p/app/src/A.vue'}};
92
+ const result=H.eventSource(payload,{product:'ide'},'/work',{readSource:()=>source});
93
+ assert.equal(result.nativeRequestStatus,'deferred-until-native-persistence');assert.deepEqual(result.calls,[]);
94
+ assert.equal(H.decision({event:'PreToolUse',source:result,binding:{entryLine:1,contextLine:0,sessionId:'s1'},tool:'Write',input:payload.tool_input,directory:'/work/prototypes/p'}).deny,true);
95
+ assert.throws(()=>E.reference(result.calls,{toolCallId:'made-up'}),/Successful native/);
96
+ assert.throws(()=>H.eventSource(payload,{product:'ide'},'/work',{readSource:()=>({...source,prompts:[{line:1,nativeRequestId:'old'}]}),pause:()=>{}}),/generation/);
97
+ });
98
+ test('only the observed project IDE root-cwd sentinel is mapped to the registered project',()=>{
99
+ const options={scope:'project',base:'/business/p/.codebuddy',product:'ide'},payload={cwd:'/',client:'CodeBuddyIDE'};
100
+ assert.equal(H.safeProject(payload,options,{},p=>p),'/business/p');
101
+ assert.throws(()=>H.safeProject({...payload,cwd:'/other'},options,{},p=>p),/outside/);
102
+ assert.throws(()=>H.safeProject(payload,{...options,product:'cli'},{},p=>p),/outside/);
103
+ assert.equal(H.safeProject({...payload,transcript_path:'/native/index.json',session_id:'s1'},{scope:'global',product:'ide'},{},p=>p,'/ignored',(file,session)=>{assert.equal(file,'/native/index.json');assert.equal(session,'s1');return '/business/p';}),'/business/p');
104
+ const cwd=H.toolCwd(payload,{product:'ide'},'/business/p');assert.equal(cwd,'/business/p');
105
+ assert.equal(H.nativeCommand('Bash',{command:'node /skills/native-pilot.cjs execution-begin prototypes/p'},cwd).root,'/business/p/prototypes/p');
106
+ });
@@ -0,0 +1,123 @@
1
+ 'use strict';
2
+ const test = require('node:test');
3
+ const assert = require('node:assert/strict');
4
+ const fs = require('node:fs');
5
+ const os = require('node:os');
6
+ const path = require('node:path');
7
+ const crypto = require('node:crypto');
8
+ const installation = path.join(__dirname, 'lib/installation.js');
9
+ const skills = fs.existsSync(installation) ? require(installation).loadInstallation().skillsDirectory : path.resolve(__dirname, '../skills');
10
+ const root = path.join(skills, 'i3-ui-prototype');
11
+ const contract = require(path.join(root, 'scripts/template-contract.cjs'));
12
+ const native = require(path.join(root, 'scripts/native-pilot-lib.cjs'));
13
+ const read = (directory, file) => JSON.parse(fs.readFileSync(path.join(directory, file), 'utf8'));
14
+ const write = (directory, file, data) => fs.writeFileSync(path.join(directory, file), JSON.stringify(data, null, 2));
15
+ const files = directory => Object.fromEntries(fs.readdirSync(directory, { recursive: true }).filter(file => fs.statSync(path.join(directory, file)).isFile()).sort().map(file => [file, crypto.createHash('sha256').update(fs.readFileSync(path.join(directory, file))).digest('hex')]));
16
+ function fixture(t, { prepare = true } = {}) {
17
+ const project = fs.mkdtempSync(path.join(os.tmpdir(), 'i3-full-content-'));
18
+ t.after(() => fs.rmSync(project, { recursive: true, force: true }));
19
+ const template = prepare ? contract.prepareTemplate({ project }) : null;
20
+ const scope = { schemaVersion: 1, title: '资产审核', pages: [{ id: 'assets', title: '资产台账', referencePage: 'models-page',
21
+ regions: [{ id: 'list', mode: 'management-list' }, { id: 'editor', mode: 'edit-form' }] }],
22
+ requirements: [{ id: 'ASSET-01', text: '查询、选择和修改资产', pages: ['assets'], source: { type: 'prd', reference: 'PRD.md#ASSET-01', statement: '按资产字段实现清单与编辑;使用模板通用能力约定。' } }], exemptions: [] };
23
+ write(project, 'scope.json', scope);
24
+ const create = options => native.createPrototype({ project, id: 'assets', expertMode: 'template', scope: path.join(project, 'scope.json'), ...(template ? { templateRoot: template.directory } : {}), ...options });
25
+ return { project, template, scope, create };
26
+ }
27
+ test('explicit legacy preparation uses the complete offline reference and never overwrites a working copy', t => {
28
+ const { project, template } = fixture(t);
29
+ const snapshot = contract.loadTemplate(template.directory);
30
+ assert.equal(snapshot.kind, 'content-reference'); assert.equal(snapshot.id, 'enterprise-admin');
31
+ assert.equal(snapshot.index.pages.length, 35); assert.equal(snapshot.index.routes.length, 44); assert.equal(snapshot.index.htmlFragments.length, 93);
32
+ assert.deepEqual(Object.keys(snapshot.index.extractor.files).sort(), ['content-template.cjs', 'template-contract.cjs']);
33
+ const file = path.join(template.directory, 'index.html');
34
+ fs.writeFileSync(file, fs.readFileSync(file, 'utf8').replace('<h1>模型管理</h1>', '<h1>资产参考内容</h1>'));
35
+ assert.throws(() => contract.loadTemplate(template.directory), /stale|不同步/);
36
+ const index = contract.syncTemplate(template.directory);
37
+ assert.equal(index.pages.find(page => page.id === 'models-page').title, '资产参考内容');
38
+ const content = require(path.join(root, 'scripts/content-template.cjs')).pageContent(contract.loadTemplate(template.directory), 'models-page');
39
+ assert.ok(content.html.includes('<h1>资产参考内容</h1>')); assert.equal(content.readOnly, true);
40
+ assert.equal(contract.prepareTemplate({ project }).created, false);
41
+ assert.ok(fs.readFileSync(file, 'utf8').includes('资产参考内容'));
42
+ });
43
+ test('default creation reads the released template without preparing a business template directory or modifying Skill assets', t => {
44
+ const { project, create } = fixture(t, { prepare: false });
45
+ const builtin = path.join(root, 'assets/templates/enterprise-admin');
46
+ const before = files(builtin);
47
+ const created = create();
48
+ assert.equal(fs.existsSync(path.join(project, 'ui-templates')), false);
49
+ assert.equal(fs.existsSync(path.join(project, '.prototype-preparation')), false);
50
+ assert.deepEqual(files(builtin), before);
51
+ assert.equal(native.inspect(created.directory).status, 'passed');
52
+ const snapshot = read(created.directory, 'inputs/template-snapshot.json');
53
+ assert.equal(snapshot.id, 'enterprise-admin'); assert.equal(snapshot.index.pages.length, 35);
54
+ assert.deepEqual(snapshot, contract.loadBundledTemplate());
55
+ assert.deepEqual(read(created.directory, 'inputs/scope.json'), read(project, 'scope.json'));
56
+ });
57
+ test('an existing edited template copy is preserved and used only when explicitly selected', t => {
58
+ const { project, template, create } = fixture(t);
59
+ const entry = path.join(template.directory, 'index.html');
60
+ fs.writeFileSync(entry, fs.readFileSync(entry, 'utf8').replace('<h1>模型管理</h1>', '<h1>用户维护的旧模板</h1>'));
61
+ contract.syncTemplate(template.directory);
62
+ const before = files(template.directory);
63
+ const current = create({ id: 'current', templateRoot: undefined });
64
+ const legacy = create({ id: 'legacy' });
65
+ assert.notEqual(read(current.directory, 'inputs/template-snapshot.json').digest, read(legacy.directory, 'inputs/template-snapshot.json').digest);
66
+ assert.equal(read(legacy.directory, 'inputs/template-snapshot.json').index.pages.find(page => page.id === 'models-page').title, '用户维护的旧模板');
67
+ assert.deepEqual(files(template.directory), before);
68
+ });
69
+ test('new business generation requires a sourced scope and does not silently generate a device list', t => {
70
+ const { project, create } = fixture(t);
71
+ assert.throws(() => create({ scope: undefined }), /scope|需求/i);
72
+ assert.equal(fs.existsSync(path.join(project, 'prototypes')), false);
73
+ const created = create(), meta = read(created.directory, 'prototype.json');
74
+ assert.equal(meta.runtime.contentReference, true); assert.equal(meta.business.status, 'needs-adaptation');
75
+ assert.equal(native.inspect(created.directory).status, 'passed');
76
+ assert.match(native.inspect(created.directory, { acceptance: true }).errors.join('; '), /implementation pending|scaffold/);
77
+ const config = read(created.directory, 'app/public/prototype-config.json');
78
+ assert.deepEqual(config.pages, [{ id: 'assets', title: '资产台账' }]);
79
+ assert.deepEqual(config.theme, { defaultTheme: 'light', presets: ['light', 'dark', 'fresh', 'tech'] });
80
+ assert.equal(config.content, undefined); assert.equal(read(created.directory, 'mocks/contract.json').count, undefined);
81
+ assert.equal(fs.existsSync(path.join(created.directory, 'app/src/domain/devices.ts')), false);
82
+ const state = native.state(created.directory, {});
83
+ assert.equal(state.nextAction, 'implement-business'); assert.equal(state.commands.browser, undefined); assert.ok(state.commands.recordRun);
84
+ });
85
+ test('the complete content reference yields fixed common capabilities for the selected business regions', t => {
86
+ const { create } = fixture(t), directory = create().directory;
87
+ const fixed = read(directory, 'inputs/capabilities.json'), acceptance = read(directory, 'inputs/acceptance.json');
88
+ for (const id of ['filter', 'selection', 'pagination', 'bulk', 'feedback']) assert.ok(fixed.bindings.some(binding => binding.id === `assets/list/${id}`));
89
+ assert.ok(fixed.bindings.some(binding => binding.id === 'assets/editor/edit'));
90
+ assert.ok(fixed.bindings.some(binding => binding.id === 'app-shell/appearance/theme'));
91
+ assert.ok(acceptance.requiredScenarios.includes('assets/list/pagination/pages'));
92
+ assert.ok(acceptance.requiredScenarios.includes('app-shell/appearance/theme/persistence'));
93
+ assert.equal(acceptance.requiredScenarios.some(id => id.startsWith('devices/')), false);
94
+ fixed.bindings = fixed.bindings.filter(binding => binding.capabilityId !== 'selection'); write(directory, 'inputs/capabilities.json', fixed);
95
+ const candidate = native.manifest(directory);
96
+ assert.throws(() => native.revise(directory, { revision: 'r002', expectedRevision: candidate.revision, expectedDigest: candidate.digest, acceptInputs: true }), /binding|capabilit|合同/i);
97
+ });
98
+ test('business page and requirement references must remain explicit and unambiguous', t => {
99
+ const { project, scope, create } = fixture(t);
100
+ for (const modify of [
101
+ value => { value.pages[0].referencePage = 'missing-page'; },
102
+ value => { value.pages.push(structuredClone(value.pages[0])); },
103
+ value => { value.requirements.push(structuredClone(value.requirements[0])); },
104
+ value => { value.requirements[0].source = {}; },
105
+ value => { value.requirements[0].pages = ['other-business']; },
106
+ value => { value.pages[0].regions[0].mode = 'unknown-mode'; },
107
+ ]) {
108
+ const broken = structuredClone(scope); modify(broken); write(project, 'scope.json', broken);
109
+ assert.throws(() => create()); assert.equal(fs.existsSync(path.join(project, 'prototypes')), false);
110
+ }
111
+ });
112
+ test('offline reference synchronization rejects missing runtime resources and module loading', t => {
113
+ const { template } = fixture(t), file = path.join(template.directory, 'index.html'), html = fs.readFileSync(file, 'utf8');
114
+ fs.writeFileSync(file, html.replace('src="offline.js"', 'src="missing.js"')); assert.throws(() => contract.syncTemplate(template.directory), /resource missing/);
115
+ fs.writeFileSync(file, html.replace('<script src="offline.js"', '<script type="module" src="offline.js"')); assert.throws(() => contract.syncTemplate(template.directory), /modules/);
116
+ fs.writeFileSync(file, html); fs.appendFileSync(path.join(template.directory, 'offline.js'), '\nfetch("parts.html")'); assert.throws(() => contract.syncTemplate(template.directory), /Offline runtime/);
117
+ });
118
+ test('the historical device regression runner refuses a business contract before writing evidence', async t => {
119
+ const { create } = fixture(t), directory = create().directory;
120
+ const before = fs.readdirSync(path.join(directory, 'qa'));
121
+ await assert.rejects(require(path.join(root, 'scripts/native-browser.cjs')).main(directory), /historical device-list/);
122
+ assert.deepEqual(fs.readdirSync(path.join(directory, 'qa')), before);
123
+ });
@@ -0,0 +1,213 @@
1
+ 'use strict';
2
+ const test = require('node:test');
3
+ const assert = require('node:assert/strict');
4
+ const fs = require('node:fs');
5
+ const os = require('node:os');
6
+ const path = require('node:path');
7
+ const installation = path.join(__dirname, 'lib/installation.js');
8
+ const skills = fs.existsSync(installation) ? require(installation).loadInstallation().skillsDirectory : path.resolve(__dirname, '../skills');
9
+ const deps = require(path.join(skills, 'i3-ui-prototype/scripts/prototype-dependencies.cjs'));
10
+ const installer = require(path.join(skills, 'i3-ui-prototype/scripts/optional-skill-install.cjs'));
11
+ function workspace(t) {
12
+ const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'i3-optional-deps-')));
13
+ t.after(() => fs.rmSync(root, { recursive: true, force: true }));
14
+ const project = path.join(root, 'business'), home = path.join(root, 'home');
15
+ fs.mkdirSync(project); fs.mkdirSync(home);
16
+ return { project, home, env: {
17
+ ...process.env,
18
+ HOME: home, USERPROFILE: home,
19
+ XDG_CONFIG_HOME: path.join(home, '.config'),
20
+ CODEBUDDY_CONFIG_DIR: path.join(home, '.codebuddy'),
21
+ WORKBUDDY_CONFIG_DIR: path.join(home, '.workbuddy'),
22
+ CLAUDE_CONFIG_DIR: path.join(home, '.claude'),
23
+ CODEX_HOME: path.join(home, '.codex'),
24
+ } };
25
+ }
26
+ test('missing optional Skills do not fail read-only inspection or write files', t => {
27
+ const options = workspace(t), before = fs.readdirSync(options.project);
28
+ const result = deps.check(options);
29
+ assert.equal(result.status, 'checked');
30
+ assert.equal(result.professionalReady, false);
31
+ assert.equal(result.antdv.skill.availability, 'missing');
32
+ assert.equal(result.antdv.tool.availability, 'missing');
33
+ assert.deepEqual(fs.readdirSync(options.project), before);
34
+ });
35
+ test('later installed antDv Skill is detected without reinstalling i3 or claiming the CLI ready', t => {
36
+ const options = workspace(t);
37
+ assert.equal(deps.check(options).antdv.skill.availability, 'missing');
38
+ const root = path.join(options.project, '.codebuddy/skills/antdv');
39
+ fs.mkdirSync(root, { recursive: true }); fs.writeFileSync(path.join(root, 'SKILL.md'), 'official Skill fixture');
40
+ const result = deps.check(options);
41
+ assert.equal(result.antdv.skill.root, root);
42
+ assert.equal(result.antdv.skill.availability, 'available');
43
+ assert.equal(result.antdv.skill.invocationVerified, false);
44
+ assert.equal(result.antdv.tool.availability, 'missing');
45
+ });
46
+ test('CodeBuddy global antDv is reusable and current project entry has precedence', t => {
47
+ const options = workspace(t);
48
+ options.env.CODEBUDDY_CONFIG_DIR = path.join(options.home, 'legacy CodeBuddy config');
49
+ const global = path.join(options.env.CODEBUDDY_CONFIG_DIR, 'skills/antdv');
50
+ fs.mkdirSync(global, { recursive: true }); fs.writeFileSync(path.join(global, 'SKILL.md'), 'global');
51
+ assert.equal(deps.skillAt(options.project, 'antdv', options).source, 'global-skill');
52
+ const local = path.join(options.project, '.codebuddy/skills/antdv');
53
+ fs.mkdirSync(local, { recursive: true }); fs.writeFileSync(path.join(local, 'SKILL.md'), 'local');
54
+ assert.equal(deps.skillAt(options.project, 'antdv', options).root, local);
55
+ });
56
+ test('global UIUX detection reuses enabled user plugins, preserves disabled status and excludes business plugins', t => {
57
+ const options = { ...workspace(t), host: 'codebuddy', scope: 'global' }, config = path.join(options.home, '.codebuddy');
58
+ const plugin = path.join(options.home, 'uiux-plugin'), root = path.join(plugin, 'skills/ui-ux-pro-max'), id = 'ui-ux-pro-max@fixture';
59
+ const write = (file, content) => { fs.mkdirSync(path.dirname(file), { recursive: true }); fs.writeFileSync(file, typeof content === 'string' ? content : JSON.stringify(content)); };
60
+ write(path.join(plugin, '.codebuddy-plugin/plugin.json'), { name: 'ui-ux-pro-max', version: '2.15.0' });
61
+ for (const file of ['SKILL.md', 'scripts/core.py', 'scripts/design_system.py', ...['ux-guidelines','styles','colors','typography','products','landing','ui-reasoning'].map(name => `data/${name}.csv`)]) write(path.join(root, file), 'synthetic fixture');
62
+ write(path.join(root, 'scripts/search.py'), 'import json\nprint(json.dumps({"domain":"ux","results":[{"id":"synthetic-probe-only"}]}))\n');
63
+ write(path.join(config, 'settings.json'), { enabledPlugins: { [id]: true } });
64
+ const record = { scope: 'user', installPath: plugin, version: '2.15.0' };
65
+ write(path.join(config, 'plugins/installed_plugins.json'), { version: 2, plugins: { [id]: [record] } });
66
+ const result = deps.check(options);
67
+ assert.equal(result.uiux.root, root);
68
+ assert.equal(result.uiux.availability, 'available');
69
+ assert.equal(result.uiux.source, 'codebuddy-plugin');
70
+ write(path.join(config, 'settings.json'), { enabledPlugins: { [id]: false } });
71
+ assert.equal(deps.check(options).uiux.availability, 'disabled');
72
+ write(path.join(config, 'settings.json'), { enabledPlugins: { [id]: true } });
73
+ write(path.join(config, 'plugins/installed_plugins.json'), { version: 2, plugins: { [id]: [{ ...record, scope: 'project', projectPath: options.project }] } });
74
+ assert.equal(deps.check(options).uiux.availability, 'missing');
75
+ });
76
+ test('dependency CLI rejects unintended writes and unrelated hosts', () => {
77
+ assert.throws(() => deps.parse(['check', '--force']), /Invalid option/);
78
+ assert.throws(() => deps.check({ host: 'not-a-host' }), /Unknown host/);
79
+ assert.throws(() => deps.parse(['update']), /check or install/);
80
+ });
81
+ function officialFixture(name, folder) {
82
+ const skill = path.join(folder, 'skill'), tool = path.join(folder, 'tool');
83
+ fs.mkdirSync(skill); fs.mkdirSync(tool);
84
+ fs.writeFileSync(path.join(skill, 'SKILL.md'), `---\nname: ${name === 'uiux' ? 'ui-ux-pro-max' : 'antdv'}\n---\nOfficial fixture; not host invocation evidence.\n`);
85
+ if (name === 'uiux') {
86
+ for (const file of ['scripts/search.py', 'scripts/core.py', 'scripts/design_system.py', 'data/ux-guidelines.csv']) {
87
+ fs.mkdirSync(path.dirname(path.join(skill, file)), { recursive: true }); fs.writeFileSync(path.join(skill, file), 'fixture');
88
+ }
89
+ } else {
90
+ const pkg = path.join(tool, 'node_modules/@antdv-next/cli'); fs.mkdirSync(pkg, { recursive: true });
91
+ fs.writeFileSync(path.join(pkg, 'package.json'), JSON.stringify({ name: '@antdv-next/cli', version: '0.0.0-beta.1', bin: { antdv: 'cli.cjs' } }));
92
+ fs.writeFileSync(path.join(pkg, 'cli.cjs'), 'process.stdout.write(JSON.stringify({name:"Table",props:[]}));');
93
+ }
94
+ return { skill, tool };
95
+ }
96
+ test('explicit skip never downloads, writes or permanently disables dependencies', async t => {
97
+ const options = workspace(t), before = fs.readdirSync(options.project);
98
+ const result = await installer.install({ ...options, skills: 'none' }, { prepare() { throw new Error('Unexpected download'); } });
99
+ assert.equal(result.status, 'skipped');
100
+ assert.deepEqual(fs.readdirSync(options.project), before);
101
+ assert.deepEqual(installer.selection('uiux,antdv'), ['uiux', 'antdv']);
102
+ assert.throws(() => installer.selection('none,uiux'), /Choose/);
103
+ });
104
+ test('optional antDv installation preserves user files and a later invocation reuses the Skill', async t => {
105
+ const options = workspace(t);
106
+ fs.writeFileSync(path.join(options.project, 'PRD.md'), 'human requirements');
107
+ const services = { prepare: officialFixture, progress() {} };
108
+ const result = await installer.install({ ...options, skills: 'antdv' }, services);
109
+ assert.equal(result.status, 'installed');
110
+ assert.equal(result.detection.antdv.skill.availability, 'available');
111
+ assert.equal(fs.readFileSync(path.join(options.project, 'PRD.md'), 'utf8'), 'human requirements');
112
+ const skillFile = path.join(options.project, '.codebuddy/skills/antdv/SKILL.md');
113
+ fs.appendFileSync(skillFile, 'user edit'); const before = fs.readFileSync(skillFile);
114
+ const again = await installer.install({ ...options, skills: 'antdv' }, { prepare() { throw new Error('Existing tool must not be replaced'); } });
115
+ assert.equal(again.status, 'reused');
116
+ assert.deepEqual(fs.readFileSync(skillFile), before);
117
+ });
118
+ test('a failed second preparation publishes neither selected Skill and removes temporary files', async t => {
119
+ const options = workspace(t);
120
+ await assert.rejects(installer.install({ ...options, skills: 'uiux,antdv' }, {
121
+ progress() {}, prepare(name, folder) { if (name === 'antdv') throw new Error('simulated network failure'); return officialFixture(name, folder); },
122
+ }), /simulated network failure/);
123
+ assert.deepEqual(fs.readdirSync(options.project), []);
124
+ });
125
+ test('antDv error text or error JSON with exit zero is not successful knowledge', async t => {
126
+ const options = workspace(t);
127
+ await installer.install({ ...options, skills: 'antdv' }, { prepare: officialFixture, progress() {} });
128
+ const file = path.join(resultTool(options), 'node_modules/@antdv-next/cli/cli.cjs');
129
+ for (const output of ['Error: Table knowledge missing', JSON.stringify({ error: 'Table knowledge missing' })]) {
130
+ fs.writeFileSync(file, `process.stdout.write(${JSON.stringify(output)});`);
131
+ assert.equal(deps.check(options).antdv.tool.availability, 'unusable');
132
+ }
133
+ });
134
+ function resultTool(options) { return path.join(deps.check(options).locations.toolsDirectory, 'antdv-cli'); }
135
+ for (const host of ['codebuddy', 'workbuddy', 'codex', 'claude-code']) for (const scope of ['project', 'global']) {
136
+ test(`${host} ${scope} optional installation keeps all tools inside the selected host root`, async t => {
137
+ const options = { ...workspace(t), host, scope };
138
+ const result = await installer.install({ ...options, skills: 'antdv' }, { prepare: officialFixture, progress() {} });
139
+ assert.equal(result.detection.antdv.tool.source, `${scope}-tool`);
140
+ assert.ok(fs.existsSync(path.join(resultTool(options), 'node_modules/@antdv-next/cli/package.json')));
141
+ assert.equal(fs.existsSync(path.join(options.project, '.prototype-tools')), false);
142
+ assert.equal(fs.existsSync(path.join(options.project, 'package.json')), false);
143
+ if (scope === 'global') assert.deepEqual(fs.readdirSync(options.project), []);
144
+ });
145
+ }
146
+ test('an available global tool is reused by a later project without project writes', async t => {
147
+ const options = workspace(t);
148
+ await installer.install({ ...options, host: 'codex', scope: 'global', skills: 'antdv' }, { prepare: officialFixture, progress() {} });
149
+ const result = await installer.install({ ...options, host: 'codex', skills: 'antdv' }, { prepare() { throw new Error('No download expected'); } });
150
+ assert.equal(result.status, 'reused');
151
+ assert.equal(result.detection.antdv.tool.source, 'global-tool');
152
+ assert.deepEqual(fs.readdirSync(options.project), []);
153
+ });
154
+ test('legacy independent tools stay readable until an explicit owned migration', async t => {
155
+ const options = workspace(t), legacy = path.join(options.project, '.prototype-tools/antdv-cli');
156
+ const stage = path.join(options.project, 'fixture'); fs.mkdirSync(stage);
157
+ const prepared = officialFixture('antdv', stage);
158
+ fs.mkdirSync(path.dirname(legacy)); fs.renameSync(prepared.tool, legacy); fs.rmSync(stage, { recursive: true });
159
+ fs.writeFileSync(path.join(legacy, 'UPSTREAM-ORIGIN.json'), JSON.stringify({ ...require(path.join(skills, 'i3-ui-prototype/assets/optional-skills/sources.json')).antdv, installer: 'i3 optional dependency preparation; official Skill body unchanged' }));
160
+ assert.equal(deps.check(options).antdv.tool.source, 'legacy-project-tool');
161
+ await installer.install({ ...options, skills: 'antdv' }, { prepare: officialFixture, progress() {} });
162
+ assert.equal(fs.existsSync(legacy), true);
163
+ fs.writeFileSync(path.join(options.project, '.prototype-tools/user-note.txt'), 'keep this location');
164
+ await installer.install({ ...options, scope: 'global', skills: 'antdv' }, { prepare: officialFixture, progress() {} });
165
+ assert.equal(deps.check(options).antdv.tool.source, 'global-tool', 'normal lookup prefers a current global tool over legacy');
166
+ const result = await installer.install({ ...options, skills: 'antdv', migrateTools: true }, { prepare() { throw new Error('No download expected'); } });
167
+ assert.equal(result.status, 'migrated');
168
+ assert.equal(result.detection.antdv.tool.source, 'project-tool');
169
+ assert.equal(fs.existsSync(legacy), false);
170
+ assert.equal(fs.readFileSync(path.join(options.project, '.prototype-tools/user-note.txt'), 'utf8'), 'keep this location');
171
+ });
172
+ test('legacy mixed tools without i3 ownership remain untouched by migration', async t => {
173
+ const options = workspace(t), stage = path.join(options.project, 'fixture'); fs.mkdirSync(stage);
174
+ const prepared = officialFixture('antdv', stage), legacy = path.join(options.project, '.prototype-tools/antdv-cli');
175
+ fs.mkdirSync(path.dirname(legacy)); fs.renameSync(prepared.tool, legacy); fs.rmSync(stage, { recursive: true });
176
+ await assert.rejects(installer.install({ ...options, skills: 'antdv', migrateTools: true }, { prepare() { throw new Error('No download expected'); } }), /ownership|来源|归属/);
177
+ assert.equal(deps.check(options).antdv.tool.source, 'legacy-project-tool');
178
+ });
179
+ test('a broken project tool is reported instead of silently borrowing a healthy global tool', async t => {
180
+ const options = workspace(t);
181
+ await installer.install({ ...options, scope: 'global', skills: 'antdv' }, { prepare: officialFixture, progress() {} });
182
+ const local = path.join(options.project, '.codebuddy/tools/i3-prototype/antdv-cli/node_modules/@antdv-next/cli');
183
+ fs.mkdirSync(local, { recursive: true }); fs.writeFileSync(path.join(local, 'package.json'), '{broken');
184
+ assert.equal(deps.check(options).antdv.tool.availability, 'unusable');
185
+ assert.equal(deps.check({ ...options, scope: 'global' }).antdv.tool.availability, 'available');
186
+ await assert.rejects(installer.install({ ...options, skills: 'antdv' }), /Existing tool unusable/);
187
+ assert.equal(fs.readFileSync(path.join(local, 'package.json'), 'utf8'), '{broken');
188
+ });
189
+ test('failed post-migration execution restores the old location and removes newly created destinations', async t => {
190
+ const options = workspace(t), stage = path.join(options.project, 'fixture'); fs.mkdirSync(stage);
191
+ const prepared = officialFixture('antdv', stage), legacy = path.join(options.project, '.prototype-tools/antdv-cli');
192
+ fs.mkdirSync(path.dirname(legacy)); fs.renameSync(prepared.tool, legacy); fs.rmSync(stage, { recursive: true });
193
+ const file = path.join(legacy, 'node_modules/@antdv-next/cli/cli.cjs');
194
+ fs.writeFileSync(file, 'if(__dirname.includes("tools/i3-prototype"))process.exit(1);process.stdout.write(JSON.stringify({name:"Table",props:[]}));');
195
+ fs.writeFileSync(path.join(legacy, 'UPSTREAM-ORIGIN.json'), JSON.stringify({ ...require(path.join(skills, 'i3-ui-prototype/assets/optional-skills/sources.json')).antdv, installer: 'i3 optional dependency preparation; official Skill body unchanged' }));
196
+ await assert.rejects(installer.install({ ...options, skills: 'antdv', migrateTools: true }, { prepare: officialFixture, progress() {} }), /tool query failed/);
197
+ assert.equal(deps.check(options).antdv.tool.source, 'legacy-project-tool');
198
+ assert.equal(fs.existsSync(path.join(options.project, '.codebuddy')), false);
199
+ assert.ok(fs.existsSync(file));
200
+ });
201
+ test('unusable existing UIUX resources are preserved without attempting a download', async t => {
202
+ const options = workspace(t), target = path.join(options.project, '.codebuddy/skills/ui-ux-pro-max');
203
+ fs.mkdirSync(target, { recursive: true }); fs.writeFileSync(path.join(target, 'SKILL.md'), 'human-owned incomplete skill');
204
+ await assert.rejects(installer.install({ ...options, skills: 'uiux' }, { prepare() { throw new Error('must not download'); } }), /Existing Skill preserved/);
205
+ assert.equal(fs.readFileSync(path.join(target, 'SKILL.md'), 'utf8'), 'human-owned incomplete skill');
206
+ });
207
+ test('optional installer rejects symlink escapes, including dangling targets, and bad download bytes', async t => {
208
+ const options = workspace(t), outside = path.join(options.project, 'outside');
209
+ fs.symlinkSync(outside, path.join(options.project, '.codebuddy'), 'dir');
210
+ await assert.rejects(installer.install({ ...options, skills: 'antdv' }, { prepare: officialFixture }), /symlink/);
211
+ assert.equal(fs.existsSync(outside), false);
212
+ assert.throws(() => installer.verify(Buffer.from('altered'), '0'.repeat(64)), /integrity mismatch/);
213
+ });
@@ -0,0 +1,53 @@
1
+ 'use strict';
2
+ const test=require('node:test'),assert=require('node:assert/strict'),fs=require('node:fs'),path=require('node:path'),os=require('node:os');
3
+ const installationFile=path.join(__dirname,'lib/installation.js');
4
+ const skills=fs.existsSync(installationFile)?require(installationFile).loadInstallation().skillsDirectory:path.resolve(__dirname,'../skills');
5
+ const review=require(path.join(skills,'i3-ui-prototype/scripts/native-devtools.cjs'));
6
+ // Synthetic contract fixtures only; none is browser or host invocation evidence.
7
+ function fixture(t){
8
+ const directory=fs.mkdtempSync(path.join(os.tmpdir(),'devtools-review-'));t.after(()=>fs.rmSync(directory,{recursive:true,force:true}));
9
+ const write=(file,value)=>{const full=path.join(directory,file);fs.mkdirSync(path.dirname(full),{recursive:true});fs.writeFileSync(full,Buffer.isBuffer(value)||typeof value==='string'?value:JSON.stringify(value));};
10
+ const current={revision:'r001',digest:'a'.repeat(64)},build={buildId:'b-fixture',buildDigest:'b'.repeat(64)};
11
+ write('inputs/acceptance.json',{browserReview:review.policy()});
12
+ const png=Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+ip1sAAAAASUVORK5CYII=','base64');
13
+ const observations={};for(const kind of review.KINDS){const evidence=`qa/fixture/${kind}.${kind==='screenshot'?'png':kind==='performance'?'json':'txt'}`;write(evidence,kind==='screenshot'?png:kind==='performance'?{traceEvents:[{name:'synthetic-fixture',ts:1}]}:`Synthetic ${kind} fixture; not executed`);observations[kind]={status:'passed',summary:'Synthetic guard fixture, not executed',evidence};}
14
+ const report={schemaVersion:1,status:'passed',revision:current.revision,candidateDigest:current.digest,...build,executor:'synthetic-unit-fixture',startedAt:'2026-10-17T00:00:00.000Z',completedAt:'2026-10-17T00:00:01.000Z',previewBaseURL:'http://127.0.0.1:4173/',skill:{name:review.policy().skill,toolCallId:'fixture-skill',evidence:'synthetic-fixture only'},mcp:{name:'chrome-devtools-mcp',version:'1.11.0',evidence:'synthetic-fixture only',calls:['evaluate_script','take_screenshot','list_console_messages','list_network_requests','take_snapshot','performance_start_trace','performance_stop_trace'].map((name,index)=>({tool:`mcp__chrome-devtools__${name}`,toolCallId:`fixture-${index}`}))},observations,openItems:[]};
15
+ const lib={manifest:()=>current,verifyBuild:(_root,id)=>{assert.equal(id,build.buildId);return build;},claim:()=>{},release:()=>{}};
16
+ return {directory,write,current,build,report,lib};
17
+ }
18
+ test('new DevTools policy is explicit; old fixed inputs remain read-only and not required',t=>{
19
+ const f=fixture(t);assert.equal(review.check(f.directory,f.lib).status,'not-run');
20
+ f.write('inputs/acceptance.json',{});const before=fs.readdirSync(f.directory);assert.deepEqual(review.check(f.directory,f.lib),{status:'not-required',required:false});assert.deepEqual(fs.readdirSync(f.directory),before);
21
+ });
22
+ test('fixed framework inputs retain all six DevTools observations and reject policy removal',t=>{
23
+ const f=fixture(t);f.write('inputs/framework.json',{acceptance:{browserReview:review.policy()}});
24
+ for(const mutate of [value=>delete value.browserReview,value=>value.browserReview.required=false,value=>value.browserReview.observations.pop()]){
25
+ const input={browserReview:review.policy()};mutate(input);f.write('inputs/acceptance.json',input);
26
+ assert.equal(review.check(f.directory,f.lib).status,'failed');assert.throws(()=>review.record(f.directory,f.report,f.lib),/policy|observations|opt out/);
27
+ }
28
+ });
29
+ test('a review binds six actual-format evidence files to current candidate and build without attesting invocation',t=>{
30
+ const f=fixture(t),before=f.current.digest;const result=review.record(f.directory,f.report,f.lib);
31
+ assert.equal(result.status,'passed');assert.equal(result.invocationVerified,false);assert.equal(f.current.digest,before);
32
+ const checked=review.check(f.directory,f.lib);assert.equal(checked.status,'current');assert.equal(Object.keys(checked.evidenceDigests).length,6);
33
+ assert.match(checked.limitations,/host transcript/);
34
+ });
35
+ test('missing observation, stale identity and substituted evidence cannot pass the review gate',t=>{
36
+ const f=fixture(t);
37
+ for(const mutate of [r=>delete r.observations.network,r=>r.observations.console.status='not-run',r=>r.candidateDigest='c'.repeat(64),r=>r.buildDigest='c'.repeat(64),r=>r.skill.name='other-skill',r=>r.mcp.calls=r.mcp.calls.filter(c=>!c.tool.endsWith('performance_stop_trace')),r=>r.observations.network.evidence=r.observations.dom.evidence]){
38
+ const report=structuredClone(f.report);mutate(report);assert.throws(()=>review.record(f.directory,report,f.lib));assert.equal(fs.existsSync(path.join(f.directory,'qa/devtools-review.json')),false);
39
+ }
40
+ review.record(f.directory,f.report,f.lib);f.write(f.report.observations.network.evidence,'changed QA artifact');assert.equal(review.check(f.directory,f.lib).status,'failed');
41
+ });
42
+ test('Playwright ZIP and mislabeled screenshots do not become DevTools trace or PNG evidence',t=>{
43
+ const f=fixture(t);f.write(f.report.observations.performance.evidence,Buffer.from('PK\x03\x04fake-playwright-trace'));assert.throws(()=>review.record(f.directory,f.report,f.lib),/trace|JSON/);
44
+ f.write(f.report.observations.performance.evidence,{traceEvents:[{name:'fixture'}]});f.write(f.report.observations.screenshot.evidence,'not a PNG');assert.throws(()=>review.record(f.directory,f.report,f.lib),/PNG/);
45
+ });
46
+ test('a truncated PNG header is not a complete screenshot artifact',t=>{
47
+ const f=fixture(t),fake=Buffer.alloc(24);Buffer.from([137,80,78,71,13,10,26,10]).copy(fake);fake.write('IHDR',12);fake.writeUInt32BE(1,16);fake.writeUInt32BE(1,20);f.write(f.report.observations.screenshot.evidence,fake);
48
+ assert.throws(()=>review.record(f.directory,f.report,f.lib),/PNG/);
49
+ });
50
+ test('evidence paths cannot escape QA through traversal or symlinks',t=>{
51
+ const f=fixture(t),r=structuredClone(f.report);r.observations.dom.evidence='../outside.txt';assert.throws(()=>review.record(f.directory,r,f.lib),/QA|path/);
52
+ const link=path.join(f.directory,'qa/link.txt');fs.symlinkSync(path.join(f.directory,f.report.observations.dom.evidence),link);r.observations.dom.evidence='qa/link.txt';assert.throws(()=>review.record(f.directory,r,f.lib),/symlink/);
53
+ });
@@ -0,0 +1,62 @@
1
+ 'use strict';
2
+ const prototypeScripts=(()=>{const fs=require('node:fs'),path=require('node:path'),file=path.join(__dirname,'lib/installation.js');return path.join(fs.existsSync(file)?require(file).loadInstallation().skillsDirectory:path.resolve(__dirname,'../skills'),'i3-ui-prototype/scripts');})();
3
+ const test = require('node:test');
4
+ const assert = require('node:assert/strict');
5
+ const E = require(prototypeScripts+'/execution-evidence.cjs');
6
+ const use = (id, name, input) => ({ type: 'assistant', message: { role: 'assistant', content: [{ type: 'tool_use', id, name, input }] } });
7
+ const result = (id, content, is_error = false) => ({ type: 'user', message: { role: 'user', content: [{ type: 'tool_result', tool_use_id: id, content, is_error }] } });
8
+ const raw = rows => rows.map(row => JSON.stringify(row)).join('\n') + '\n';
9
+ test('paired native results distinguish loaded skills from attempts and failures', () => {
10
+ const stream = raw([use('f1', 'Skill', { skill: 'i3-frontend-ui-engineering' }), result('f1', 'Full skill instructions'),
11
+ use('f2', 'Skill', { skill: 'antdv' }), result('f2', 'Not available', true), use('f3', 'Skill', { skill: 'ui-ux-pro-max' })]);
12
+ const calls = E.parse(stream);
13
+ assert.equal(calls[0].skill, 'frontend'); assert.equal(calls[0].success, true);
14
+ assert.equal(calls[1].success, false); assert.equal(calls[2].success, false);
15
+ assert.equal(E.loaded(calls, 'frontend').length, 1); assert.equal(E.loaded(calls, 'antdv').length, 0);
16
+ });
17
+ test('Read, forged names, zero-exit errors and duplicate IDs do not become valid skill loads', () => {
18
+ const calls = E.parse(raw([use('r1', 'Read', { file_path: 'SKILL.md' }), result('r1', 'instructions'),
19
+ use('b1', 'Bash', { command: 'node antdv info' }), result('b1', JSON.stringify({ exitCode: 0, stdout: 'Error: missing knowledge' }))]));
20
+ assert.equal(E.loaded(calls, 'frontend').length, 0); assert.equal(calls[1].success, false);
21
+ assert.throws(() => E.parse(raw([use('a', 'Skill', { skill: 'antdv' }), use('a', 'Skill', { skill: 'antdv' })])), /Duplicate/);
22
+ assert.throws(() => E.parse(raw([result('unknown', 'success')])), /unmatched|Unmatched/);
23
+ });
24
+ test('evidence references must resolve to the actual response and retain original order', () => {
25
+ const calls = E.parse(raw([use('a', 'Skill', { skill: 'antdv' }), result('a', 'official skill'), use('b', 'Bash', { command: 'node cli info Drawer' }), result('b', 'Drawer API') ]));
26
+ assert.equal(E.reference(calls, { toolCallId: 'b', resultDigest: calls[1].resultDigest }).name, 'Bash');
27
+ assert.throws(() => E.reference(calls, { toolCallId: 'b', resultDigest: '0'.repeat(64) }), /digest/);
28
+ assert.throws(() => E.reference(calls, { toolCallId: 'not-real' }), /missing/);
29
+ assert.ok(calls[0].resultLine < calls[1].requestLine);
30
+ });
31
+ test('malformed streams and model-written audit JSON are not accepted as native transcripts', () => {
32
+ assert.throws(() => E.parse('{broken\n'), /JSON/);
33
+ assert.throws(() => E.parse(JSON.stringify({ skill: 'frontend', used: true, passed: true })), /native|Native/);
34
+ });
35
+ // Structure captured from a real CodeBuddy Code 2.169.0 installation smoke run.
36
+ // Skill instruction bodies are replaced; identities, pairing and status remain.
37
+ const modern = (id='native-1', status='completed') => [
38
+ {type:'message',role:'user',sessionId:'session-169',content:[{type:'input_text',text:'请验收安装并加载前端 Skill'}]},
39
+ {type:'function_call',name:'Skill',callId:id,arguments:'{"skill":"i3-frontend-ui-engineering"}',sessionId:'session-169',message:{usage:{}}},
40
+ {type:'function_call_result',name:'Skill',callId:id,status,output:{type:'text',text:'Full frontend Skill instructions'},sessionId:'session-169'},
41
+ {type:'message',role:'assistant',sessionId:'session-169',content:[{type:'output_text',text:'安装检查已完成'}]},
42
+ ];
43
+ test('CodeBuddy 2.169 flat messages and function calls retain native ordering and session evidence', () => {
44
+ const text=raw(modern()),calls=E.parse(text),description=E.describe(text);
45
+ assert.equal(E.loaded(calls,'frontend').length,1);
46
+ assert.equal(calls[0].sessionId,'session-169');
47
+ assert.equal(calls[0].requestLine,2);assert.equal(calls[0].resultLine,3);
48
+ assert.equal(description.prompts[0].text,'请验收安装并加载前端 Skill');
49
+ assert.equal(description.latestAssistant,'安装检查已完成');
50
+ });
51
+ test('flat failed, pending, empty and Read results cannot satisfy a frontend Skill gate', () => {
52
+ for(const status of ['failed','pending','cancelled'])assert.equal(E.loaded(E.parse(raw(modern('a',status))),'frontend').length,0);
53
+ const empty=modern();empty[2].output.text='';assert.equal(E.loaded(E.parse(raw(empty)),'frontend').length,0);
54
+ const read=modern();read[1].name=read[2].name='Read';read[1].arguments='{"file_path":"SKILL.md"}';
55
+ assert.equal(E.loaded(E.parse(raw(read)),'frontend').length,0);
56
+ });
57
+ test('flat native calls reject mismatched identity, malformed arguments and cross-session results', () => {
58
+ const wrong=modern();wrong[2].name='Read';assert.throws(()=>E.parse(raw(wrong)),/name|identity/i);
59
+ const bad=modern();bad[1].arguments='{broken';assert.throws(()=>E.parse(raw(bad)),/argument|JSON/i);
60
+ const session=modern();session[2].sessionId='other';assert.throws(()=>E.parse(raw(session)),/session/i);
61
+ const duplicate=modern();duplicate.splice(2,0,duplicate[1]);assert.throws(()=>E.parse(raw(duplicate)),/Duplicate/);
62
+ });