@givolt/i3-composer-agent 2.3.2 → 2.3.4

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 (100) hide show
  1. package/.agents/plugins/marketplace.json +1 -1
  2. package/.claude-plugin/marketplace.json +1 -1
  3. package/.claude-plugin/plugin.json +1 -1
  4. package/.codex-plugin/plugin.json +1 -1
  5. package/README.md +5 -1
  6. package/bin/agent-targets.json +4 -2
  7. package/bin/i3-composer-agent.cjs +48 -9
  8. package/bin/install.cjs +2 -1
  9. package/bin/native-layout.cjs +1 -1
  10. package/bin/tool-selector.cjs +24 -7
  11. package/bin/wizard.cjs +49 -15
  12. package/docs/codebuddy-setup.md +43 -4
  13. package/docs/codex-setup.md +3 -1
  14. package/docs/other-hosts.md +1 -1
  15. package/docs/ui-prototype-native/USAGE.md +4 -2
  16. package/docs/workbuddy-setup.md +20 -2
  17. package/hooks/codebuddy-upstream.cjs +93 -0
  18. package/hooks/i3-prototype.cjs +12 -0
  19. package/hooks/manage.cjs +170 -0
  20. package/package.json +35 -1
  21. package/plugin.json +1 -1
  22. package/scripts/prototype-cache-test.js +55 -0
  23. package/scripts/prototype-generation-entry-test.js +6 -0
  24. package/scripts/prototype-generation-evidence-test.js +10 -0
  25. package/scripts/prototype-generation-native-test.js +78 -0
  26. package/scripts/prototype-generation-test.js +99 -0
  27. package/scripts/prototype-inventory-test.js +43 -0
  28. package/scripts/prototype-locations-test.js +21 -0
  29. package/scripts/prototype-project-test.js +183 -0
  30. package/scripts/ui-prototype-buddy-lifecycle-test.js +89 -0
  31. package/scripts/ui-prototype-codebuddy-hook-test.js +106 -0
  32. package/scripts/ui-prototype-dependencies-test.js +13 -0
  33. package/scripts/ui-prototype-execution-evidence-test.js +62 -0
  34. package/scripts/ui-prototype-execution-gates-test.js +90 -0
  35. package/scripts/ui-prototype-execution-policy-test.js +53 -0
  36. package/scripts/ui-prototype-execution-runtime-test.js +27 -0
  37. package/scripts/ui-prototype-ide-source-test.js +73 -0
  38. package/scripts/ui-prototype-recovery-test.js +72 -0
  39. package/scripts/ui-prototype-registration-test.js +53 -0
  40. package/scripts/ui-prototype-transcript-auto-test.js +67 -0
  41. package/skills/i3-frontend-ui-engineering/SKILL.md +1 -1
  42. package/skills/i3-incremental-implementation/SKILL.md +1 -1
  43. package/skills/i3-ui-prototype/SKILL.md +29 -7
  44. package/skills/i3-ui-prototype/assets/capabilities/generation.json +833 -0
  45. package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/generation-component-map.json +300 -0
  46. package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/starter/src/App.vue +14 -3
  47. package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/starter/src/PrototypeShell.vue +59 -0
  48. package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/starter/src/config.ts +6 -1
  49. package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/starter/src/domain/presentation.ts +29 -0
  50. package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/starter/src/style.css +13 -0
  51. package/skills/i3-ui-prototype/assets/frameworks/vue-antdv-next/starter/tests/presentation.test.ts +23 -0
  52. package/skills/i3-ui-prototype/assets/hosts/codebuddy/profile.json +17 -0
  53. package/skills/i3-ui-prototype/assets/optional-skills/host-targets.json +4 -2
  54. package/skills/i3-ui-prototype/assets/templates/catalog.json +2 -1
  55. package/skills/i3-ui-prototype/assets/templates/enterprise-admin/component-inventory.json +707 -0
  56. package/skills/i3-ui-prototype/assets/templates/enterprise-admin/generation.json +49 -0
  57. package/skills/i3-ui-prototype/assets/templates/enterprise-admin/template-inventory.json +28791 -0
  58. package/skills/i3-ui-prototype/assets/templates/gallery/index.html +1 -1
  59. package/skills/i3-ui-prototype/assets/templates/generation-options.js +42 -0
  60. package/skills/i3-ui-prototype/assets/templates/presentation.html +17 -0
  61. package/skills/i3-ui-prototype/references/artifact-contract.md +1 -1
  62. package/skills/i3-ui-prototype/references/content-template.md +9 -4
  63. package/skills/i3-ui-prototype/references/entry-contract.md +1 -1
  64. package/skills/i3-ui-prototype/references/execution-evidence.md +83 -0
  65. package/skills/i3-ui-prototype/references/generation-contract.md +122 -0
  66. package/skills/i3-ui-prototype/references/interaction-acceptance.md +5 -1
  67. package/skills/i3-ui-prototype/references/optional-skills.md +4 -0
  68. package/skills/i3-ui-prototype/references/project-layout.md +92 -0
  69. package/skills/i3-ui-prototype/references/template-adapters.md +6 -0
  70. package/skills/i3-ui-prototype/references/workflow-contract.md +4 -0
  71. package/skills/i3-ui-prototype/scripts/browser-workspace.cjs +72 -0
  72. package/skills/i3-ui-prototype/scripts/buddy-host.cjs +40 -0
  73. package/skills/i3-ui-prototype/scripts/codebuddy-context.cjs +75 -0
  74. package/skills/i3-ui-prototype/scripts/codebuddy-hook.cjs +196 -0
  75. package/skills/i3-ui-prototype/scripts/codebuddy-ide-source.cjs +69 -0
  76. package/skills/i3-ui-prototype/scripts/codebuddy-registration.cjs +125 -0
  77. package/skills/i3-ui-prototype/scripts/content-template.cjs +2 -2
  78. package/skills/i3-ui-prototype/scripts/execution-assertions.cjs +43 -0
  79. package/skills/i3-ui-prototype/scripts/execution-evidence.cjs +146 -0
  80. package/skills/i3-ui-prototype/scripts/execution-gates.cjs +168 -0
  81. package/skills/i3-ui-prototype/scripts/execution-policy.cjs +62 -0
  82. package/skills/i3-ui-prototype/scripts/execution-recovery.cjs +52 -0
  83. package/skills/i3-ui-prototype/scripts/execution-runtime.cjs +60 -0
  84. package/skills/i3-ui-prototype/scripts/generation-assets.cjs +18 -0
  85. package/skills/i3-ui-prototype/scripts/generation-contract.cjs +214 -0
  86. package/skills/i3-ui-prototype/scripts/generation-evidence.cjs +50 -0
  87. package/skills/i3-ui-prototype/scripts/generation-replan.cjs +34 -0
  88. package/skills/i3-ui-prototype/scripts/hosts/codebuddy.cjs +20 -0
  89. package/skills/i3-ui-prototype/scripts/native-command.cjs +18 -0
  90. package/skills/i3-ui-prototype/scripts/native-delivery.cjs +17 -5
  91. package/skills/i3-ui-prototype/scripts/native-devtools.cjs +1 -0
  92. package/skills/i3-ui-prototype/scripts/native-pilot-lib.cjs +64 -18
  93. package/skills/i3-ui-prototype/scripts/native-pilot.cjs +23 -3
  94. package/skills/i3-ui-prototype/scripts/project-registry.cjs +126 -0
  95. package/skills/i3-ui-prototype/scripts/prototype-dependencies.cjs +28 -6
  96. package/skills/i3-ui-prototype/scripts/prototype-hooks.cjs +87 -0
  97. package/skills/i3-ui-prototype/scripts/prototype-lib.cjs +5 -7
  98. package/skills/i3-ui-prototype/scripts/prototype-locations.cjs +5 -1
  99. package/skills/i3-ui-prototype/scripts/template-contract.cjs +11 -2
  100. package/skills/i3-ui-prototype/scripts/template-inventory.cjs +78 -0
@@ -0,0 +1,99 @@
1
+ 'use strict';
2
+ const test = require('node:test'), assert = require('node:assert/strict'), fs = require('node:fs'), path = require('node:path');
3
+ const install = path.join(__dirname, 'lib/installation.js');
4
+ const skills = fs.existsSync(install) ? require(install).loadInstallation().skillsDirectory : path.resolve(__dirname, '../skills');
5
+ const root = path.join(skills, 'i3-ui-prototype'), templates = require(path.join(root, 'scripts/template-contract.cjs'));
6
+ const generation = require(path.join(root, 'scripts/generation-contract.cjs'));
7
+ function scope(pattern = 'management-list') {
8
+ return { schemaVersion: 3, moduleIds: ['assets'], pages: [{ id: 'assets', title: '资产', referencePage: 'models-page', regions: [{ id: 'main', mode: 'content', pattern }] }], requirements: [{ id: 'A-01', text: '按实际资产需求适配参考页面', pages: ['assets'], source: { type: 'prd', reference: 'fixture/A-01', statement: 'Synthetic contract fixture; not user or runtime acceptance.' } }], generation: { presentation: { layout: 'mixed', style: 'minimal', density: 'compact' }, decisions: [] } };
9
+ }
10
+ test('selected page patterns derive complete abilities and frozen customer presentation choices', () => {
11
+ const fixed = generation.compile(templates.loadBundledTemplate(), scope());
12
+ assert.deepEqual(fixed.presentation, { layout: 'mixed', style: 'minimal', density: 'compact' });
13
+ for (const capability of ['filter', 'selection', 'pagination', 'bulk', 'columns', 'feedback']) assert.ok(fixed.bindings.some(binding => binding.capabilityId === capability));
14
+ assert.ok(fixed.scenarios.some(scenario => scenario.id.includes('/columns/')));
15
+ assert.equal(fixed.bindings.find(binding => binding.capabilityId === 'pagination').parameters.defaultPageSize, 10);
16
+ assert.ok(fixed.referenceCoverage[0].events.length && fixed.referenceCoverage[0].nodes.length);
17
+ });
18
+ test('tree, tree table, wizard, canvas, permissions and dialogs have distinct required behavior', () => {
19
+ const snapshot = templates.loadBundledTemplate();
20
+ for (const [pattern, required] of Object.entries({ 'tree-list': ['hierarchy', 'pagination'], 'tree-table': ['hierarchy', 'columns'], wizard: ['wizard', 'dirty'], workflow: ['canvas', 'dirty'], permissions: ['authorization', 'hierarchy'], 'edit-form': ['edit', 'dialog', 'dirty'], detail: ['detail'] })) {
21
+ const fixed = generation.compile(snapshot, scope(pattern));
22
+ for (const capability of required) assert.ok(fixed.bindings.some(binding => binding.capabilityId === capability), `${pattern}/${capability}`);
23
+ }
24
+ });
25
+ test('unknown choices and deleting a required pattern capability fail instead of weakening the contract', () => {
26
+ const snapshot = templates.loadBundledTemplate(), input = scope(), fixed = generation.compile(snapshot, input);
27
+ for (const [key,value] of Object.entries({ layout: 'invented-layout', style: 'invented-style', density: 'invisible' })) {
28
+ const changed = structuredClone(input); changed.generation.presentation[key] = value;
29
+ assert.throws(() => generation.compile(snapshot, changed), /Unsupported|choice/);
30
+ }
31
+ fixed.bindings = fixed.bindings.filter(binding => binding.capabilityId !== 'columns');
32
+ assert.throws(() => generation.verify(snapshot, input, fixed), /contract|generation/i);
33
+ input.generation.decisions = [{ pageId: 'assets', capabilityId: 'columns', decision: 'not-applicable', requirementId: 'A-01', reason: 'Do not test columns' }];
34
+ assert.throws(() => generation.compile(snapshot, input), /required/i);
35
+ });
36
+ test('reference applicability is explicit and omissions remain pending until sourced decisions exist', () => {
37
+ const snapshot = templates.loadBundledTemplate(), input = scope(), fixed = generation.compile(snapshot, input);
38
+ assert.ok(fixed.pendingDecisions.length);
39
+ input.generation.decisions = fixed.pendingDecisions.map(item => ({ ...item, decision: 'not-applicable', requirementId: 'A-01', reason: 'This fixture intentionally narrows its stated business scope.' }));
40
+ assert.equal(generation.compile(snapshot, input).pendingDecisions.length, 0);
41
+ input.generation.decisions[0].reason = '';
42
+ assert.throws(() => generation.compile(snapshot, input), /reason|source/i);
43
+ });
44
+ test('frozen generation rules remain independent from future global catalog changes', () => {
45
+ const snapshot = templates.loadBundledTemplate(), input = scope(), fixed = generation.compile(snapshot, input);
46
+ assert.equal(generation.verify(snapshot, input, fixed).status, 'passed');
47
+ const changed = structuredClone(input); changed.pages[0].regions[0].pattern = 'tree-list';
48
+ assert.throws(() => generation.verify(snapshot, changed, fixed), /contract|generation/i);
49
+ });
50
+ test('business fields and actions remain sourced and receive individual acceptance identities', () => {
51
+ const snapshot = templates.loadBundledTemplate(), input = scope(), region = input.pages[0].regions[0];
52
+ region.fields = [{ id: 'name', label: '资产名称', kind: 'text', required: true, requirementId: 'A-01' }];
53
+ region.actions = [{ id: 'disable', label: '停用所选资产', kind: 'state-change', target: 'selected', outcome: '只有所选资产变为停用', requirementId: 'A-01' }];
54
+ const fixed = generation.compile(snapshot, input);
55
+ assert.equal(fixed.pages[0].regions[0].fields[0].label, '资产名称');
56
+ assert.ok(fixed.scenarios.some(item => item.id.endsWith('/action/disable')));
57
+ region.actions[0].requirementId = 'unknown';
58
+ assert.throws(() => generation.compile(snapshot, input), /source|requirement/);
59
+ });
60
+ test('unknown parameters and attempts to disable selected behavior fail validation', () => {
61
+ const snapshot = templates.loadBundledTemplate(), input = scope();
62
+ input.pages[0].regions[0].parameters = { columns: { enabled: false } };
63
+ assert.throws(() => generation.compile(snapshot, input), /parameter|Unsupported/i);
64
+ });
65
+ test('navigation groups preserve every declared page once and reject missing or duplicate routes', () => {
66
+ const snapshot = templates.loadBundledTemplate(), input = scope();
67
+ input.generation.navigation = [{ id: 'assets-group', title: '资产管理', children: [{ pageId: 'assets' }] }];
68
+ assert.equal(generation.compile(snapshot, input).navigation[0].children[0].key, 'assets');
69
+ input.generation.navigation[0].children.push({ pageId: 'assets' });
70
+ assert.throws(() => generation.compile(snapshot, input), /Duplicate|navigation/i);
71
+ });
72
+ test('every referenced functional node requires an explicit mapped or excluded disposition',()=>{
73
+ const templates=require(path.join(root,'scripts/template-contract.cjs')),lib=require(path.join(root,'scripts/generation-contract.cjs')),snapshot=templates.loadBundledTemplate();
74
+ const scope={schemaVersion:3,pages:[{id:'assets',title:'资产',referencePage:'models-page',regions:[{id:'list',pattern:'management-list'}]}],requirements:[{id:'A-01',pages:['assets'],source:{type:'prd',reference:'fixture',statement:'Fixture only.'}}],generation:{}};
75
+ const fixed=lib.compile(snapshot,scope);assert.ok(fixed.pendingReferenceElements.length);const first=fixed.pendingReferenceElements[0];
76
+ scope.generation.referenceDecisions=[{pageId:first.pageId,nodeIds:[first.nodeId],decision:'not-applicable',requirementId:'A-01',reason:'Synthetic fixture excludes this example node.'}];
77
+ assert.equal(lib.compile(snapshot,scope).pendingReferenceElements.length,fixed.pendingReferenceElements.length-1);
78
+ scope.generation.referenceDecisions.push(scope.generation.referenceDecisions[0]);assert.throws(()=>lib.compile(snapshot,scope),/duplicate reference element/);
79
+ });
80
+ test('fixed pagination and modal form scenarios match their actual parameters',()=>{
81
+ const templates=require(path.join(root,'scripts/template-contract.cjs')),lib=require(path.join(root,'scripts/generation-contract.cjs')),snapshot=templates.loadBundledTemplate();
82
+ const scope={schemaVersion:3,pages:[{id:'assets',title:'资产',referencePage:'models-page',regions:[{id:'list',pattern:'management-list',parameters:{pagination:{mode:'fixed',fixedPageSize:15,minimumTestPages:3}}},{id:'edit',pattern:'edit-form',parameters:{edit:{presentation:'modal'}}}]}],requirements:[{id:'A-01',pages:['assets'],source:{type:'prd',reference:'fixture',statement:'Fixture only.'}}]};
83
+ const fixed=lib.compile(snapshot,scope);assert.match(fixed.scenarios.find(s=>s.capabilityId==='pagination'&&s.id.endsWith('page-size')).expected,/固定容量/);assert.equal(fixed.bindings.find(s=>s.capabilityId==='dialog').parameters.presentation,'modal');
84
+ scope.pages[0].regions[1].parameters.dialog={presentation:'drawer'};assert.throws(()=>lib.compile(snapshot,scope),/presentation|呈现/);
85
+ });
86
+ test('optional shell and second-region capabilities can be adopted without borrowing another region binding',()=>{
87
+ const snapshot=templates.loadBundledTemplate(),input=scope();input.pages[0].regions.push({id:'second',pattern:'content'});
88
+ input.generation.decisions=[{pageId:'app-shell',regionId:'shell',capabilityId:'menu',decision:'adopted',requirementId:'A-01',reason:'Fixture shell menu.'},{pageId:'assets',regionId:'second',capabilityId:'columns',decision:'adopted',requirementId:'A-01',reason:'Fixture second table.'}];
89
+ const fixed=generation.compile(snapshot,input);assert.ok(fixed.bindings.some(b=>b.id==='app-shell/shell/menu'));assert.ok(fixed.bindings.some(b=>b.id==='assets/second/columns'));
90
+ });
91
+ test('adopted reference controls get individual scenarios even when they share a core capability',()=>{
92
+ const snapshot=templates.loadBundledTemplate(),input=scope(),draft=generation.compile(snapshot,input),node=draft.pendingReferenceElements.find(item=>item.events.length&&(!item.capabilities.length||item.capabilities.includes('interaction')));
93
+ input.generation.referenceDecisions=[{pageId:node.pageId,nodeIds:[node.nodeId],decision:'mapped',bindingIds:['app-shell/shell/interaction'],requirementId:'A-01',reason:'Fixture adapts this control to its sourced outcome.'}];
94
+ const fixed=generation.compile(snapshot,input);assert.ok(fixed.scenarios.some(item=>item.referenceNodeId===node.nodeId&&item.bindingId==='app-shell/shell/interaction'));
95
+ });
96
+ test('read-only planning exposes compact summaries and individual reference fragments',()=>{
97
+ const fixed=generation.compile(templates.loadBundledTemplate(),scope()),summary=generation.view(fixed);assert.equal(summary.readOnly,true);assert.ok(summary.referenceReview.pendingCount>0);assert.ok(!summary.definition);assert.ok(Buffer.byteLength(JSON.stringify(summary))<25000);
98
+ const group=summary.referenceReview.fragments[0],detail=generation.view(fixed,{page:group.pageId,fragment:group.fragment});assert.ok(detail.referenceReview.elements.length);assert.ok(detail.referenceReview.elements.every(item=>item.fragment===group.fragment));assert.throws(()=>generation.view(fixed,{page:'missing'}),/page/);
99
+ });
@@ -0,0 +1,43 @@
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 install = path.join(__dirname, 'lib/installation.js');
4
+ const skills = fs.existsSync(install) ? require(install).loadInstallation().skillsDirectory : path.resolve(__dirname, '../skills');
5
+ const root = path.join(skills, 'i3-ui-prototype'), inventory = require(path.join(root, 'scripts/template-inventory.cjs'));
6
+ const builtin = path.join(root, 'assets/templates/enterprise-admin');
7
+ test('the complete reference inventory accounts for every route, fragment, component and event', () => {
8
+ const value = inventory.scan(builtin);
9
+ assert.equal(value.routes.length, 44); assert.equal(value.pages.length, 35); assert.equal(value.fragments.length, 93);
10
+ assert.equal(Object.keys(value.components).length, 116); assert.equal(value.eventCount, 595);
11
+ assert.equal(value.unknownComponents.length, 0);
12
+ assert.ok(value.fragments.every(fragment => fragment.nodes.length && fragment.digest));
13
+ assert.ok(value.fragments.every(fragment => fragment.nodes.every(node => node.id && node.tag && node.attributes)));
14
+ assert.equal(value.components.Tree.classification.capabilities.includes('hierarchy'), true);
15
+ assert.ok(value.files['reference/src/shared/columns.js']);
16
+ assert.ok(value.components.ColumnSettings.classification.source.endsWith('ColumnSettings.html'));
17
+ });
18
+ function fixture(t) {
19
+ const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'i3-inventory-'));
20
+ t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
21
+ fs.writeFileSync(path.join(directory, 'index.html'), '<script type="application/json" id="prototype-offline-routes">[{"path":"/items","component":"./Items.js"}]</script>\n<template data-source="reference/Items.html">\n<main><template data-component="Button" data-on-click="save">保存</template></main>\n</template>\n');
22
+ fs.writeFileSync(path.join(directory, 'component-inventory.json'), JSON.stringify({ schemaVersion: 1, components: { Button: { kind: 'framework', capabilities: ['interaction'] } } }));
23
+ return directory;
24
+ }
25
+ test('a removed event, route or non-HTML source file cannot keep the old complete inventory', t => {
26
+ const directory = fixture(t), file = path.join(directory, 'index.html'), html = fs.readFileSync(file, 'utf8');
27
+ fs.writeFileSync(path.join(directory, 'domain.js'), 'original behavior');
28
+ const baseline = inventory.scan(directory);
29
+ for (const change of [html.replace(' data-on-click="save"', ''), html.replace('/items', '/different')]) {
30
+ fs.writeFileSync(file, change); assert.throws(() => inventory.verify(directory, baseline), /inventory|changed|清单/i);
31
+ }
32
+ fs.writeFileSync(file, html); fs.unlinkSync(path.join(directory, 'domain.js'));
33
+ assert.throws(() => inventory.verify(directory, baseline), /inventory|changed|清单/i);
34
+ });
35
+ test('unclassified components and unsafe template files fail closed', t => {
36
+ const directory = fixture(t), file = path.join(directory, 'index.html');
37
+ fs.appendFileSync(file, '\n<template data-source="reference/Other.html">\n<template data-component="NewWidget"></template>\n</template>\n');
38
+ assert.throws(() => inventory.scan(directory), /Unclassified.*NewWidget/);
39
+ fs.unlinkSync(file); fs.writeFileSync(file, '<script id="prototype-offline-routes">[]</script>');
40
+ fs.symlinkSync(os.tmpdir(), path.join(directory, 'outside'));
41
+ assert.throws(() => inventory.scan(directory), /symlink/i);
42
+ });
43
+ test('quoted comparison expressions remain complete nodes in replacement templates',t=>{const directory=fixture(t),file=path.join(directory,'index.html');fs.writeFileSync(file,fs.readFileSync(file,'utf8').replace('<main>','<main data-bind-visible="a > b && c < d">'));const fixed=inventory.scan(directory);assert.equal(fixed.fragments[0].nodes[0].attributes['data-bind-visible'],'a > b && c < d');});
@@ -22,15 +22,36 @@ test('all registered project and global targets keep tools under their host base
22
22
  const toolBase = path.dirname(local.skillsDirectory) === options.project ? path.join(options.project, `.${agent.id}`) : path.dirname(local.skillsDirectory);
23
23
  assert.equal(local.toolsDirectory, path.join(toolBase, 'tools/i3-prototype'));
24
24
  assert.notEqual(local.toolsDirectory, path.join(options.project, 'tools/i3-prototype'));
25
+ assert.equal(local.cacheDirectory, path.join(toolBase, 'cache/i3-prototype'));
25
26
  if (agent.globalSkillsDir) {
26
27
  const global = locations.resolveLocations({ ...options, host: agent.id, scope: 'global' });
27
28
  assert.equal(global.toolsDirectory, path.join(path.dirname(global.skillsDirectory), 'tools/i3-prototype'));
28
29
  assert.ok(global.base.startsWith(options.home + path.sep));
30
+ assert.equal(global.cacheDirectory, path.join(global.base, 'cache/i3-prototype', global.projectKey));
29
31
  } else assert.throws(() => locations.resolveLocations({ ...options, host: agent.id, scope: 'global' }), /project installation only/);
30
32
  }
31
33
  assert.deepEqual(fs.readdirSync(options.project), []);
32
34
  assert.deepEqual(fs.readdirSync(options.home), []);
33
35
  });
36
+ test('global caches distinguish same-name projects while path resolution remains read-only', t => {
37
+ const options = workspace(t), other = path.join(options.home, 'business'); fs.mkdirSync(other);
38
+ const first = locations.resolveLocations({ ...options, host: 'codebuddy', scope: 'global' });
39
+ const second = locations.resolveLocations({ ...options, project: other, host: 'codebuddy', scope: 'global' });
40
+ assert.notEqual(first.projectKey, second.projectKey);
41
+ assert.notEqual(first.cacheDirectory, second.cacheDirectory);
42
+ assert.deepEqual(fs.readdirSync(options.project), []);
43
+ assert.equal(fs.existsSync(path.join(options.home, '.codebuddy')), false);
44
+ });
45
+ test('CLI path output distinguishes installation scope from the preparation scope file without writes', async t => {
46
+ const options = workspace(t), cli = require(path.join(skills, 'i3-ui-prototype/scripts/native-pilot.cjs'));
47
+ for (const scope of ['project', 'global']) {
48
+ const result = await cli.main(['paths', '--project', options.project, '--home', options.home, '--host', 'codebuddy', '--scope', scope, '--id', 'assets']);
49
+ assert.equal(result.scope, scope);
50
+ assert.equal(result.scopeFile, path.join(options.project, 'prototypes/.preparation/assets/scope.json'));
51
+ }
52
+ assert.deepEqual(fs.readdirSync(options.project), []);
53
+ assert.deepEqual(fs.readdirSync(options.home), []);
54
+ });
34
55
  test('CodeBuddy and WorkBuddy share project tools but use separate global roots; Codex follows agents', t => {
35
56
  const options = workspace(t), get = (host, scope = 'project') => locations.resolveLocations({ ...options, host, scope });
36
57
  assert.equal(get('codebuddy').toolsDirectory, get('workbuddy').toolsDirectory);
@@ -0,0 +1,183 @@
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 scripts = path.join(skills, 'i3-ui-prototype/scripts');
10
+ const native = require(path.join(scripts, 'native-pilot-lib.cjs'));
11
+ const registry = require(path.join(scripts, 'project-registry.cjs'));
12
+ const read = file => JSON.parse(fs.readFileSync(file, 'utf8'));
13
+ function fixture(t) {
14
+ const project = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'i3-project-')));
15
+ t.after(() => fs.rmSync(project, { recursive: true, force: true }));
16
+ const scope = { schemaVersion: 2, moduleIds: ['asset-management', 'work-orders', 'asset-management'], title: '资产原型',
17
+ pages: [{ id: 'assets', title: '资产台账', referencePage: 'models-page', regions: [{ id: 'list', mode: 'management-list' }] }],
18
+ requirements: [{ id: 'ASSET-01', text: '查询、多选、分页和批量状态变更', pages: ['assets'], source: { type: 'prd', reference: 'PRD-assets-v1.2#ASSET-01', statement: 'Isolated fixture with explicitly named project modules.' } }], exemptions: [] };
19
+ const file = path.join(project, 'scope.json');
20
+ const create = (id = 'assets') => { fs.writeFileSync(file, JSON.stringify(scope)); return native.createPrototype({ project, id, scope: file, expertMode: 'template' }); };
21
+ return { project, scope, create };
22
+ }
23
+ test('new prototypes use one shared nested index and derive module associations from fixed scope', t => {
24
+ const { project, scope, create } = fixture(t), created = create();
25
+ const index = read(path.join(project, 'prototypes/index.json'));
26
+ assert.deepEqual(index.prototypes[0].appliesTo, ['asset-management', 'work-orders']);
27
+ assert.equal(index.prototypes[0].directory, 'prototypes/assets');
28
+ assert.deepEqual(read(path.join(created.directory, 'inputs/scope.json')), scope);
29
+ for (const name of ['.i3-ui-prototypes.json', '.i3-ui-prototypes.lock', '.prototype-preparation', '.playwright-cli', 'ui-templates']) assert.equal(fs.existsSync(path.join(project, name)), false, name);
30
+ assert.equal(fs.existsSync(path.join(project, 'prototypes/.index.lock')), false);
31
+ });
32
+ test('scope module validation rejects invented paths and malformed declarations before any output', t => {
33
+ const { project, scope, create } = fixture(t);
34
+ for (const value of [undefined, null, 'assets', [null], ['../assets'], ['src/assets'], ['*'], [''], ['asset management']]) {
35
+ scope.moduleIds = value;
36
+ assert.throws(() => create(), /moduleIds|module identifier/i);
37
+ assert.equal(fs.existsSync(path.join(project, 'prototypes')), false);
38
+ }
39
+ scope.moduleIds = [];
40
+ create();
41
+ assert.deepEqual(registry.query({ project, module: 'asset-management' }).matches, []);
42
+ assert.deepEqual(registry.query({ project }).unbound.map(entry => entry.id), ['assets']);
43
+ });
44
+ test('index lookup is read-only, detects overlapping prototypes and never treats unbound as global', t => {
45
+ const { project, scope, create } = fixture(t);
46
+ assert.equal(registry.query({ project }).prototypes.length, 0);
47
+ assert.deepEqual(fs.readdirSync(project), []);
48
+ create('first'); create('second');
49
+ const result = registry.query({ project, module: 'asset-management' });
50
+ assert.equal(result.selection, 'ambiguous');
51
+ assert.deepEqual(result.matches.map(entry => entry.id), ['first', 'second']);
52
+ scope.moduleIds = []; create('exploration');
53
+ assert.equal(registry.query({ project, module: 'work-orders' }).matches.length, 2);
54
+ assert.equal(registry.query({ project, module: 'other' }).matches.length, 0);
55
+ });
56
+ test('a legacy-only project keeps one active index until explicitly migrated and preserves its instances', t => {
57
+ const { project, create } = fixture(t), first = create('old');
58
+ const before = native.manifest(first.directory);
59
+ fs.renameSync(path.join(project, 'prototypes/index.json'), path.join(project, '.i3-ui-prototypes.json'));
60
+ create('new');
61
+ assert.equal(fs.existsSync(path.join(project, 'prototypes/index.json')), false);
62
+ assert.equal(registry.query({ project }).legacy, true);
63
+ assert.equal(read(path.join(project, '.i3-ui-prototypes.json')).prototypes.length, 2);
64
+ assert.deepEqual(native.manifest(first.directory), before);
65
+ });
66
+ test('conflicting indices and an old active writer block creation without overwriting user content', t => {
67
+ const { project, create } = fixture(t); create('first');
68
+ const before = fs.readFileSync(path.join(project, 'prototypes/index.json'));
69
+ fs.writeFileSync(path.join(project, '.i3-ui-prototypes.json'), JSON.stringify({ schemaVersion: 1, prototypes: [] }));
70
+ assert.throws(() => create('second'), /conflict|indices/i);
71
+ assert.equal(fs.existsSync(path.join(project, 'prototypes/second')), false);
72
+ assert.deepEqual(fs.readFileSync(path.join(project, 'prototypes/index.json')), before);
73
+ fs.unlinkSync(path.join(project, '.i3-ui-prototypes.json'));
74
+ fs.writeFileSync(path.join(project, '.i3-ui-prototypes.lock'), 'existing writer');
75
+ assert.throws(() => create('second'), /lock|writer/i);
76
+ assert.equal(fs.readFileSync(path.join(project, '.i3-ui-prototypes.lock'), 'utf8'), 'existing writer');
77
+ });
78
+ test('failed atomic index publication removes only the newly created prototype', t => {
79
+ const { project, create } = fixture(t); create('first');
80
+ const original = fs.readFileSync(path.join(project, 'prototypes/index.json'));
81
+ const rename = fs.renameSync;
82
+ fs.renameSync = (from, to) => { if (to === path.join(project, 'prototypes/index.json')) throw new Error('fixture index write failed'); return rename(from, to); };
83
+ try { assert.throws(() => create('second'), /fixture index write failed/); } finally { fs.renameSync = rename; }
84
+ assert.deepEqual(fs.readFileSync(path.join(project, 'prototypes/index.json')), original);
85
+ assert.equal(fs.existsSync(path.join(project, 'prototypes/second')), false);
86
+ assert.deepEqual(fs.readdirSync(path.join(project, 'prototypes')).sort(), ['first', 'index.json']);
87
+ });
88
+ test('old scope contracts remain valid and legacy HTML creation also uses the shared index', t => {
89
+ const { project, scope, create } = fixture(t);
90
+ scope.schemaVersion = 1; delete scope.moduleIds;
91
+ const first = create();
92
+ assert.equal(native.inspect(first.directory).status, 'passed');
93
+ assert.deepEqual(registry.query({ project }).unbound.map(entry => entry.id), ['assets']);
94
+ const legacy = require(path.join(scripts, 'prototype-lib.cjs'));
95
+ const html = legacy.initPrototype({ project, id: 'html', template: 'classic-admin', expertMode: 'template' });
96
+ assert.equal(legacy.checkPrototype(html.directory).status, 'passed');
97
+ assert.equal(registry.query({ project }).prototypes.length, 2);
98
+ assert.equal(fs.existsSync(path.join(project, '.i3-ui-prototypes.json')), false);
99
+ });
100
+ test('legacy human module labels remain searchable without rewriting their old scope', t => {
101
+ const { project, scope, create } = fixture(t); scope.schemaVersion = 1; delete scope.moduleIds;
102
+ const directory = create().directory, before = native.manifest(directory), file = path.join(project, 'prototypes/index.json'), index = read(file);
103
+ index.prototypes[0].appliesTo = ['资产管理']; fs.writeFileSync(file, JSON.stringify(index));
104
+ assert.equal(registry.query({ project, module: '资产管理' }).matches[0].id, 'assets');
105
+ assert.deepEqual(native.manifest(directory), before);
106
+ });
107
+ test('explicitly accepting a revised scope updates the index and invalid associations cannot be selected', t => {
108
+ const { project, create } = fixture(t), directory = create().directory;
109
+ const scopeFile = path.join(directory, 'inputs/scope.json'), scope = read(scopeFile);
110
+ scope.moduleIds = ['maintenance']; fs.writeFileSync(scopeFile, JSON.stringify(scope));
111
+ assert.equal(registry.query({ project, module: 'asset-management' }).status, 'failed');
112
+ assert.deepEqual(registry.query({ project, module: 'asset-management' }).matches, []);
113
+ const candidate = native.manifest(directory);
114
+ native.revise(directory, { revision: 'r002', expectedRevision: candidate.revision, expectedDigest: candidate.digest, acceptInputs: true });
115
+ assert.deepEqual(read(path.join(project, 'prototypes/index.json')).prototypes[0].appliesTo, ['maintenance']);
116
+ assert.equal(registry.query({ project, module: 'maintenance' }).selection, 'matched');
117
+ assert.equal(native.inspect(directory).status, 'passed');
118
+ });
119
+ test('synchronizing the index uses fixed accepted inputs and leaves the candidate unchanged', t => {
120
+ const { project, create } = fixture(t), directory = create().directory, before = native.manifest(directory);
121
+ const indexFile = path.join(project, 'prototypes/index.json'), index = read(indexFile);
122
+ index.prototypes[0].appliesTo = ['invented']; fs.writeFileSync(indexFile, JSON.stringify(index));
123
+ assert.equal(registry.query({ project, module: 'invented' }).status, 'failed');
124
+ const rename = fs.renameSync; let instanceLocked = false;
125
+ fs.renameSync = (from, to) => { if (to === indexFile) instanceLocked = fs.existsSync(path.join(directory, '.prototype-write.lock')); return rename(from, to); };
126
+ try { assert.equal(native.synchronizeIndex({ project, id: 'assets' }).status, 'synchronized'); } finally { fs.renameSync = rename; }
127
+ assert.equal(instanceLocked, true, 'Synchronization must lock the candidate while reading its fixed inputs');
128
+ assert.deepEqual(native.manifest(directory), before);
129
+ assert.equal(registry.query({ project, module: 'invented' }).matches.length, 0);
130
+ assert.equal(registry.query({ project, module: 'asset-management' }).matches.length, 1);
131
+ const scope = read(path.join(directory, 'inputs/scope.json')); scope.moduleIds = ['pending'];
132
+ fs.writeFileSync(path.join(directory, 'inputs/scope.json'), JSON.stringify(scope));
133
+ assert.throws(() => native.synchronizeIndex({ project, id: 'assets' }), /input|contract|合同/i);
134
+ });
135
+ test('an index failure while revising restores prototype metadata and preserves the previous registry', t => {
136
+ const { project, create } = fixture(t), directory = create().directory;
137
+ const metadataFile = path.join(directory, 'prototype.json'), before = fs.readFileSync(metadataFile);
138
+ const indexFile = path.join(project, 'prototypes/index.json'), indexBefore = fs.readFileSync(indexFile);
139
+ const scopeFile = path.join(directory, 'inputs/scope.json'), scope = read(scopeFile); scope.moduleIds = ['maintenance']; fs.writeFileSync(scopeFile, JSON.stringify(scope));
140
+ const candidate = native.manifest(directory), rename = fs.renameSync;
141
+ fs.renameSync = (from, to) => { if (to === indexFile) throw new Error('fixture revise index failure'); return rename(from, to); };
142
+ try { assert.throws(() => native.revise(directory, { revision: 'r002', expectedRevision: candidate.revision, expectedDigest: candidate.digest, acceptInputs: true }), /fixture revise index failure/); }
143
+ finally { fs.renameSync = rename; }
144
+ assert.deepEqual(fs.readFileSync(metadataFile), before);
145
+ assert.deepEqual(fs.readFileSync(indexFile), indexBefore);
146
+ });
147
+ test('legacy index migration previews without writes, archives source bytes and preserves prototype identity', t => {
148
+ const { project, create } = fixture(t), directory = create().directory, candidate = native.manifest(directory);
149
+ const current = path.join(project, 'prototypes/index.json'), old = path.join(project, '.i3-ui-prototypes.json');
150
+ fs.renameSync(current, old); const bytes = fs.readFileSync(old), before = fs.readdirSync(path.join(project, 'prototypes'));
151
+ const preview = registry.migrate({ project, check: true });
152
+ assert.equal(preview.status, 'migration-preview');
153
+ assert.equal(fs.existsSync(current), false);
154
+ assert.deepEqual(fs.readdirSync(path.join(project, 'prototypes')), before);
155
+ const result = registry.migrate({ project });
156
+ assert.equal(result.status, 'migrated');
157
+ assert.deepEqual(fs.readFileSync(result.backup), bytes);
158
+ assert.deepEqual(fs.readFileSync(current), bytes);
159
+ assert.equal(fs.existsSync(old), false);
160
+ assert.deepEqual(native.manifest(directory), candidate);
161
+ assert.equal(registry.migrate({ project }).status, 'current');
162
+ });
163
+ test('failed migration rolls back its new index and retains original source and user files', t => {
164
+ const { project, create } = fixture(t); create();
165
+ const current = path.join(project, 'prototypes/index.json'), old = path.join(project, '.i3-ui-prototypes.json');
166
+ fs.renameSync(current, old); const bytes = fs.readFileSync(old);
167
+ fs.writeFileSync(path.join(project, 'user.md'), 'keep user content');
168
+ const unlink = fs.unlinkSync;
169
+ fs.unlinkSync = file => { if (file === old) throw new Error('fixture old source cannot be removed'); return unlink(file); };
170
+ try { assert.throws(() => registry.migrate({ project }), /fixture old source/); } finally { fs.unlinkSync = unlink; }
171
+ assert.deepEqual(fs.readFileSync(old), bytes);
172
+ assert.equal(fs.existsSync(current), false);
173
+ assert.equal(fs.readFileSync(path.join(project, 'user.md'), 'utf8'), 'keep user content');
174
+ assert.equal(fs.existsSync(path.join(project, 'prototypes/.index.lock')), false);
175
+ assert.equal(fs.existsSync(path.join(project, '.i3-ui-prototypes.lock')), false);
176
+ });
177
+ test('migration refuses divergent duplicate indices rather than picking a winner', t => {
178
+ const { project, create } = fixture(t); create();
179
+ const current = path.join(project, 'prototypes/index.json'), bytes = fs.readFileSync(current);
180
+ fs.writeFileSync(path.join(project, '.i3-ui-prototypes.json'), '{"schemaVersion":1,"prototypes":[]}');
181
+ assert.throws(() => registry.migrate({ project }), /conflict|indices/i);
182
+ assert.deepEqual(fs.readFileSync(current), bytes);
183
+ });
@@ -0,0 +1,89 @@
1
+ 'use strict';
2
+ // Real filesystem/Hook handlers with explicitly synthetic native events, not host acceptance.
3
+ const test=require('node:test'),assert=require('node:assert/strict'),fs=require('node:fs'),os=require('node:os'),path=require('node:path');
4
+ const installed=path.join(__dirname,'lib/installation.js');
5
+ const scripts=path.join(fs.existsSync(installed)?require(installed).loadInstallation().skillsDirectory:path.resolve(__dirname,'../skills'),'i3-ui-prototype/scripts');
6
+ const H=require(path.join(scripts,'codebuddy-hook.cjs')),R=require(path.join(scripts,'codebuddy-registration.cjs')),C=require(path.join(scripts,'codebuddy-context.cjs'));
7
+ const P=require(path.join(scripts,'execution-policy.cjs')),A=require(path.join(scripts,'execution-assertions.cjs')),lib=require(path.join(scripts,'native-pilot-lib.cjs')),X=require(path.join(scripts,'execution-runtime.cjs'));
8
+ function fixture(t,host,scope,client){
9
+ const root=fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(),'i3-buddy-lifecycle-fixture-')));t.after(()=>fs.rmSync(root,{recursive:true,force:true}));
10
+ const project=path.join(root,'business'),home=path.join(root,'isolated-home');fs.mkdirSync(project);fs.mkdirSync(home);
11
+ const scopeFile=path.join(root,'scope.json');fs.writeFileSync(scopeFile,JSON.stringify({schemaVersion:1,title:'Synthetic fixture',pages:[{id:'orders',title:'Orders',referencePage:'models-page',regions:[{id:'list',mode:'management-list'}]}],requirements:[{id:'R1',text:'Synthetic contract only',pages:['orders'],source:{type:'prd',reference:'fixture',statement:'Not native host or business acceptance'}}],exemptions:[]}));
12
+ const opts={project,scope:scopeFile,expertMode:'template'};
13
+ const facts=A.template(opts);for(const item of facts.scenarios)item.checks=[{method:'evaluate_script',pointer:'/fixture',operator:'equals',expected:true}];
14
+ const assertions=path.join(root,'facts.json');fs.writeFileSync(assertions,JSON.stringify(facts));
15
+ const created=lib.createPrototype({...opts,id:'orders',host,executionPolicy:P.AUTO_ID,executionAssertions:assertions});
16
+ const base=scope==='project'?path.join(project,'.codebuddy'):path.join(home,`.${host}`),entry=path.join(scripts,'hosts/codebuddy.cjs');
17
+ const registered=R.plan({action:'enable',scope,host,base,project,nodePath:process.execPath,scriptPath:entry,scriptDigest:C.bundle(entry)});R.apply(registered);
18
+ const file=client?path.join(root,'native.jsonl'):path.join(home,`.${host}`,'projects','business','fixture-native-session.jsonl'),sessionId='fixture-native-session',rows=[];let number=0;
19
+ fs.mkdirSync(path.dirname(file),{recursive:true});
20
+ function append(row){
21
+ if(!client){
22
+ const block=Array.isArray(row.message?.content)?row.message.content[0]:null;
23
+ if(block?.type==='tool_use')row={type:'function_call',callId:block.id,name:block.name,arguments:JSON.stringify(block.input)};
24
+ else if(block?.type==='tool_result')row={type:'function_call_result',callId:block.tool_use_id,output:block.content,status:block.is_error?'failed':'completed'};
25
+ else row={type:'message',role:row.type,content:[{type:row.type==='user'?'input_text':'output_text',text:row.message?.content||''}]};
26
+ }
27
+ rows.push({...row,sessionId,cwd:project});fs.writeFileSync(file,rows.map(value=>JSON.stringify(value)).join('\n')+'\n');
28
+ }
29
+ function hook(event,name,input,id){return H.handle({client,hook_event_name:event,session_id:sessionId,transcript_path:file,cwd:project,tool_name:name,tool_input:input,tool_use_id:id},{event,base,scope,product:'auto',home,env:{}});}
30
+ function request(name,input){const id='fixture-call-'+(++number);append({type:'assistant',message:{content:[{type:'tool_use',id,name,input}]}});return id;}
31
+ function result(id,content,failed=false){append({type:'user',message:{content:[{type:'tool_result',tool_use_id:id,content,is_error:failed}]}});}
32
+ function tool(name,input,content='success',action=()=>{}){
33
+ const id=request(name,input),decision=hook('PreToolUse',name,input,id);
34
+ if(decision.deny){result(id,decision.reason,true);return {id,decision};}
35
+ action();result(id,content);return {id,decision,post:hook('PostToolUse',name,input,id)};
36
+ }
37
+ append({type:'user',message:{content:'实现原型,使用模板模式。Synthetic fixture only.'}});
38
+ tool('Skill',{skill:'i3-ui-prototype'},'Prototype Skill loaded');
39
+ const begin=()=>tool('Bash',{command:`node '${path.join(scripts,'native-pilot.cjs')}' execution-begin '${created.directory}'`},'bound');begin();
40
+ return {root,project,base,directory:created.directory,append,hook,tool,begin,result,request,read(){const pointer=JSON.parse(fs.readFileSync(path.join(created.directory,'qa/execution-context.json')));const selected=C.select({project,product:client==='CodeBuddyCLI'||!client&&host==='codebuddy'?'cli':'ide',host,home,env:{}});return JSON.parse(fs.readFileSync(path.join(C.sessionDirectory(selected,project,pointer.sessionId),'session.json')));}};
41
+ }
42
+ for(const [host,client] of [['codebuddy','CodeBuddyCLI'],['codebuddy','CodeBuddyIDE'],['workbuddy','WorkBuddy'],['codebuddy',null],['workbuddy',null]])for(const scope of ['project','global'])test(`${host} ${client||'native-flat-log'} ${scope}: deny, correlate successful Skills, retry and retain budget`,t=>{
43
+ const f=fixture(t,host,scope,client),file=path.join(f.directory,'app/src/RecoveryFixture.vue');
44
+ const denied=f.tool('Write',{file_path:file,content:'fixture'});assert.equal(denied.decision.deny,true);assert.equal(fs.existsSync(file),false);
45
+ assert.match(denied.decision.reason,/i3-frontend-ui-engineering/);
46
+ const before=Object.values(f.read().recoveries)[0].total;f.begin();assert.equal(Object.values(f.read().recoveries)[0].total,before);
47
+ const frontend=f.tool('Skill',{skill:'i3-frontend-ui-engineering'},'Frontend Skill successfully loaded');
48
+ const official=f.tool('Skill',{skill:'antdv'},'Official Skill successfully loaded');
49
+ const retry=f.tool('Write',{file_path:file,content:'fixture'},'written',()=>fs.writeFileSync(file,'fixture'));
50
+ assert.equal(retry.decision.continue,true);assert.equal(fs.readFileSync(file,'utf8'),'fixture');
51
+ const recovery=f.read().bindings[f.directory].recovery;assert.equal(recovery.status,'recovered');assert.equal(recovery.retry.toolCallId,retry.id);
52
+ assert.deepEqual(recovery.invocations.map(item=>item.toolCallId).sort(),[frontend.id,official.id].sort());
53
+ assert.equal(X.check(f.directory,'delivery',lib).status,'failed','Recovery is not business acceptance or a delivery receipt');
54
+ });
55
+ test('Stop asks for missing Skills twice, then reports blocked without an endless loop',t=>{
56
+ const f=fixture(t,'codebuddy','project','CodeBuddyCLI');
57
+ for(let n=0;n<3;n++){
58
+ f.append({type:'assistant',message:{content:'已完成,可以交付 '+n}});
59
+ const answer=f.hook('Stop');
60
+ if(n<2)assert.equal(answer.feedback,true);else {assert.equal(answer.continue,true);assert.equal(answer.recovery.status,'blocked');}
61
+ }
62
+ f.append({type:'user',message:{content:'先分析,不改代码'}});
63
+ const answer=f.tool('Write',{file_path:path.join(f.directory,'app/src/NoWrite.vue'),content:'no'});assert.equal(answer.decision.deny,true);assert.match(answer.decision.reason,/read-only/);
64
+ });
65
+ test('a duplicated native callback does not spend recovery budget when the transcript grows',t=>{
66
+ const f=fixture(t,'workbuddy','project',null),input={file_path:path.join(f.directory,'app/src/Duplicate.vue'),content:'fixture'};
67
+ const id=f.request('Write',input);assert.equal(f.hook('PreToolUse','Write',input,id).deny,true);
68
+ f.append({type:'assistant',message:{content:'native transcript metadata was persisted'}});
69
+ assert.equal(f.hook('PreToolUse','Write',input,id).deny,true);
70
+ assert.equal(Object.values(f.read().recoveries)[0].total,1);
71
+ });
72
+ test('stage recovery waits for its shared gate and the matching successful command result',t=>{
73
+ const f=fixture(t,'codebuddy','project','CodeBuddyCLI');
74
+ f.tool('Skill',{skill:'i3-frontend-ui-engineering'});f.tool('Skill',{skill:'antdv'});
75
+ // This test covers the Hook/recovery boundary; execution-runtime tests exercise
76
+ // the real shared predicate with genuine source/evidence fixtures separately.
77
+ const original=X.check;t.after(()=>{X.check=original;});
78
+ X.check=()=>({status:'failed',errors:['Synthetic implementation evidence missing']});
79
+ const command=`node '${path.join(scripts,'native-pilot.cjs')}' build '${f.directory}'`;
80
+ assert.equal(f.tool('Bash',{command}).decision.deny,true);
81
+ X.check=()=>({status:'passed'});
82
+ const file=path.join(f.directory,'app/src/Unrelated.vue');
83
+ f.tool('Write',{file_path:file,content:'unrelated'},'written',()=>fs.writeFileSync(file,'unrelated'));
84
+ assert.equal(f.read().bindings[f.directory].recovery.status,'required','An unrelated successful UI edit cannot resolve a stage failure');
85
+ const retry=f.tool('Bash',{command},'successful native build command');
86
+ const recovery=f.read().bindings[f.directory].recovery;
87
+ assert.equal(recovery.status,'recovered');assert.equal(recovery.retry.toolCallId,retry.id);
88
+ assert.equal(recovery.retry.kind,'stage');assert.equal(recovery.retry.stage,'implementation');assert.equal(recovery.retry.operation,'build');
89
+ });
@@ -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,env:{},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
+ });
@@ -131,6 +131,19 @@ test('antDv error text or error JSON with exit zero is not successful knowledge'
131
131
  assert.equal(deps.check(options).antdv.tool.availability, 'unusable');
132
132
  }
133
133
  });
134
+ test('local CLI handoff uses the real Node entry with no PATH command and rejects failed business queries',async t=>{
135
+ const options={...workspace(t),host:'workbuddy'};options.env.PATH='';
136
+ await installer.install({...options,skills:'antdv'},{prepare:officialFixture,progress(){}});
137
+ const detected=deps.check(options).antdv.tool;
138
+ assert.equal(detected.nodePath,process.execPath);assert.equal(detected.invocationVerified,false);
139
+ const result=deps.query({...options,kind:'info',component:'Table'});
140
+ assert.equal(result.status,'queried');assert.equal(result.command[0],process.execPath);assert.equal(result.command[1],detected.entry);
141
+ assert.equal(result.data.name,'Table');assert.equal(result.skillInvocationVerified,false);
142
+ const file=path.join(resultTool(options),'node_modules/@antdv-next/cli/cli.cjs');
143
+ fs.writeFileSync(file,'const v=process.argv[process.argv.indexOf("--ver")+1];process.stdout.write(v==="1.5.3"?"Error: missing target knowledge":JSON.stringify({name:"Table",props:[]}));');
144
+ assert.equal(deps.query({...options,kind:'info',component:'Table',version:'1.5.3'}).status,'failed');
145
+ assert.throws(()=>deps.query({...options,kind:'upgrade'}),/read-only/);
146
+ });
134
147
  function resultTool(options) { return path.join(deps.check(options).locations.toolsDirectory, 'antdv-cli'); }
135
148
  for (const host of ['codebuddy', 'workbuddy', 'codex', 'claude-code']) for (const scope of ['project', 'global']) {
136
149
  test(`${host} ${scope} optional installation keeps all tools inside the selected host root`, async t => {