@punal_manalan/blender-project-harness 0.0.0-stage → 1.0.2

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 (91) hide show
  1. package/.kilo/agent/blender-grader.md +197 -0
  2. package/.kilo/agent/blender-worker.md +127 -0
  3. package/.kilo/blender-project-harness/config.example.json +71 -0
  4. package/.kilo/blender-project-harness/tools/harness/EXPECTED_FLOW.md +656 -0
  5. package/.kilo/blender-project-harness/tools/harness/blender-operations.mjs +882 -0
  6. package/.kilo/blender-project-harness/tools/harness/blender-operations.test.mjs +550 -0
  7. package/.kilo/blender-project-harness/tools/harness/blender.mjs +301 -0
  8. package/.kilo/blender-project-harness/tools/harness/blender.test.mjs +87 -0
  9. package/.kilo/blender-project-harness/tools/harness/cli.mjs +127 -0
  10. package/.kilo/blender-project-harness/tools/harness/documents.mjs +201 -0
  11. package/.kilo/blender-project-harness/tools/harness/download.mjs +94 -0
  12. package/.kilo/blender-project-harness/tools/harness/download.test.mjs +63 -0
  13. package/.kilo/blender-project-harness/tools/harness/engine.mjs +1866 -0
  14. package/.kilo/blender-project-harness/tools/harness/engine.test.mjs +2219 -0
  15. package/.kilo/blender-project-harness/tools/harness/intake.mjs +571 -0
  16. package/.kilo/blender-project-harness/tools/harness/intake.test.mjs +181 -0
  17. package/.kilo/blender-project-harness/tools/harness/package-lock.json +1227 -0
  18. package/.kilo/blender-project-harness/tools/harness/package.json +15 -0
  19. package/.kilo/blender-project-harness/tools/harness/plugin.mjs +550 -0
  20. package/.kilo/blender-project-harness/tools/harness/plugin.test.mjs +526 -0
  21. package/.kilo/blender-project-harness/tools/harness/proxy.mjs +222 -0
  22. package/.kilo/blender-project-harness/tools/harness/proxy.test.mjs +217 -0
  23. package/.kilo/blender-project-harness/tools/harness/scaffold.mjs +83 -0
  24. package/.kilo/blender-project-harness/tools/harness/scaffold.test.mjs +74 -0
  25. package/.kilo/blender-project-harness/tools/harness/server.mjs +24 -0
  26. package/.kilo/blender-project-harness/tools/harness/shell-policy.mjs +196 -0
  27. package/.kilo/blender-project-harness/tools/harness/shell-policy.test.mjs +68 -0
  28. package/.kilo/blender-project-harness/tools/harness/templates/project/Documents/ASSETS.md +29 -0
  29. package/.kilo/blender-project-harness/tools/harness/templates/project/Documents/DECISIONS.md +8 -0
  30. package/.kilo/blender-project-harness/tools/harness/templates/project/Documents/Goals/01-project-brief.md +71 -0
  31. package/.kilo/blender-project-harness/tools/harness/templates/project/Documents/Goals/README.md +17 -0
  32. package/.kilo/blender-project-harness/tools/harness/templates/project/Documents/INTAKE.md +8 -0
  33. package/.kilo/blender-project-harness/tools/harness/templates/project/Documents/README.md +57 -0
  34. package/.kilo/blender-project-harness/tools/harness/templates/project/Documents/REQUIREMENTS.md +64 -0
  35. package/.kilo/blender-project-harness/tools/harness/templates/project/Documents/REVIEW.md +29 -0
  36. package/.kilo/blender-project-harness/tools/harness/templates/project/Documents/References/README.md +18 -0
  37. package/.kilo/blender-project-harness/tools/harness/templates/project/Documents/Reviews/README.md +48 -0
  38. package/.kilo/blender-project-harness/tools/harness/templates/project/Documents/TASKSTRACKER.md +10 -0
  39. package/.kilo/blender-project-harness/tools/harness/templates/project/Documents/Tasks/README.md +4 -0
  40. package/.kilo/blender-project-harness/tools/harness/templates/project/Documents/WORKFLOW.md +36 -0
  41. package/.kilo/blender-project-harness/tools/harness/templates/project/README.md +130 -0
  42. package/.kilo/blender-project-harness/tools/harness/templates/project/assets/README.md +31 -0
  43. package/.kilo/blender-project-harness/tools/harness/templates/project/assets/audio/.gitkeep +0 -0
  44. package/.kilo/blender-project-harness/tools/harness/templates/project/assets/external/README.md +43 -0
  45. package/.kilo/blender-project-harness/tools/harness/templates/project/assets/external/SOURCE_TEMPLATE.md +18 -0
  46. package/.kilo/blender-project-harness/tools/harness/templates/project/assets/hdri/.gitkeep +0 -0
  47. package/.kilo/blender-project-harness/tools/harness/templates/project/assets/models/.gitkeep +0 -0
  48. package/.kilo/blender-project-harness/tools/harness/templates/project/assets/textures/.gitkeep +0 -0
  49. package/.kilo/blender-project-harness/tools/harness/templates/project/backups/.gitkeep +0 -0
  50. package/.kilo/blender-project-harness/tools/harness/templates/project/backups/README.md +5 -0
  51. package/.kilo/blender-project-harness/tools/harness/templates/project/exports/.gitkeep +0 -0
  52. package/.kilo/blender-project-harness/tools/harness/templates/project/exports/README.md +11 -0
  53. package/.kilo/blender-project-harness/tools/harness/templates/project/renders/README.md +7 -0
  54. package/.kilo/blender-project-harness/tools/harness/templates/project/renders/final/.gitkeep +0 -0
  55. package/.kilo/blender-project-harness/tools/harness/templates/project/renders/wip/.gitkeep +0 -0
  56. package/.kilo/blender-project-harness/tools/harness/templates/project/scenes/README.md +10 -0
  57. package/.kilo/blender-project-harness/tools/harness/templates/project/scripts/README.md +16 -0
  58. package/.kilo/blender-project-harness/tools/harness/test-fixture.mjs +91 -0
  59. package/.kilo/blender-project-harness/tools/harness/workspace.mjs +30 -0
  60. package/.kilo/blender-project-harness/tools/harness/workspace.test.mjs +31 -0
  61. package/.kilo/blender-project-harness/tools/test_blender_tools.py +427 -0
  62. package/.kilo/plugins/blender-workflow.js +3 -0
  63. package/.kilo/skills/blender-assets/SKILL.md +99 -0
  64. package/.kilo/skills/blender-mcp/SKILL.md +147 -0
  65. package/.kilo/skills/blender-mcp/references/README.md +22 -0
  66. package/.kilo/skills/blender-mcp/references/animation.md +83 -0
  67. package/.kilo/skills/blender-mcp/references/bpy.md +97 -0
  68. package/.kilo/skills/blender-mcp/references/level-design.md +53 -0
  69. package/.kilo/skills/blender-mcp/references/materials.md +80 -0
  70. package/.kilo/skills/blender-mcp/references/retopology.md +104 -0
  71. package/.kilo/skills/blender-mcp/references/rigging.md +123 -0
  72. package/.kilo/skills/blender-mcp/references/scene.md +72 -0
  73. package/.kilo/skills/blender-project-goal/SKILL.md +115 -0
  74. package/.kilo/skills/blender-project-grading/SKILL.md +184 -0
  75. package/.kilo/skills/blender-project-readme/SKILL.md +46 -0
  76. package/.kilo/skills/blender-project-requirements/SKILL.md +84 -0
  77. package/.kilo/skills/blender-project-scaffold/SKILL.md +59 -0
  78. package/.kilo/skills/blender-project-workflow/SKILL.md +96 -0
  79. package/.kilo/skills/blender-subagents/SKILL.md +204 -0
  80. package/.kilo/skills/blender-workflow-harness/SKILL.md +187 -0
  81. package/AGENTS.md +234 -0
  82. package/Blender/README.md +30 -0
  83. package/CHANGELOG.md +22 -0
  84. package/LICENSE +21 -0
  85. package/README.md +368 -2
  86. package/THIRD_PARTY_NOTICES.md +51 -0
  87. package/bin/blender-project-harness.mjs +4 -0
  88. package/installer/cli.mjs +116 -0
  89. package/installer/install.mjs +325 -0
  90. package/installer/install.test.mjs +134 -0
  91. package/package.json +58 -6
@@ -0,0 +1,526 @@
1
+ import assert from 'node:assert/strict';
2
+ import fs from 'node:fs';
3
+ import test from 'node:test';
4
+ import path from 'node:path';
5
+ import { createHooks } from './plugin.mjs';
6
+ import { OPERATION_NAMES } from './blender-operations.mjs';
7
+ import { git, Harness } from './engine.mjs';
8
+ import { fixture } from './test-fixture.mjs';
9
+
10
+ function hooks(f, metadata) {
11
+ const client = { session: { get: async ({ path: p }) => ({ data: { id: p.id, ...(metadata ? { metadata } : {}) } }) } };
12
+ return createHooks(f.harness, client);
13
+ }
14
+ const before = (h, session, tool, args) => h['tool.execute.before']({ sessionID: session, callID: 'call_test', tool }, { args });
15
+ async function approveShell(h, session, command, reason = 'Inspect the workspace for a bounded diagnosis.') {
16
+ let warning;
17
+ try { await before(h, session, 'bash', { command }); } catch (error) { warning = error; }
18
+ assert.ok(warning, 'first shell call must warn');
19
+ const id = warning.message.match(/HARNESS_SHELL:([a-f0-9-]{36})/)?.[1];
20
+ assert.ok(id, warning.message);
21
+ await before(h, session, 'bash', { command, description: `Reason: ${reason} [HARNESS_SHELL:${id}]` });
22
+ return id;
23
+ }
24
+
25
+ test('plugin denies raw code and unsupported production escape routes', async (t) => {
26
+ const f = fixture(t), h = hooks(f); f.begin();
27
+ for (const tool of ['blender_execute_blender_code', 'blender_import_asset', 'blender_generate_3d', 'mediamcp_generate_video', 'use_mcp_tool']) {
28
+ await assert.rejects(before(h, f.session, tool, {}), /disabled|unclassified/);
29
+ }
30
+ await assert.doesNotReject(before(h, f.session, 'agent_manager', { mode: 'local', versions: false, tasks: [], action: null }));
31
+ await assert.doesNotReject(before(h, f.session, 'background_process', { command: 'node .kilo/blender-project-harness/tools/harness/cli.mjs doctor' }));
32
+ for (const command of ['python -c "print(1)"', 'git diff --output=scene.blend', 'git status; node bypass.mjs', 'git add -- .', 'git -c alias.x=anything x']) {
33
+ await assert.rejects(before(h, f.session, 'bash', { command }), /denied|literal|approval|scope|blender_workflow commit/);
34
+ }
35
+ });
36
+
37
+ test('unregistered tooling commit messages may mention the engine name', async (t) => {
38
+ const f = fixture(t);
39
+ const harness = new Harness(f.root, { stateRoot: path.join(path.dirname(f.root), 'unregistered-state') });
40
+ const h = hooks({ harness });
41
+ await approveShell(h, 'ses_unregistered', 'git commit -m "docs: Blender harness notes"');
42
+ await assert.rejects(before(h, 'ses_unregistered', 'bash', {
43
+ command: 'C:\\Program Files\\Blender Foundation\\Blender\\blender.exe --background',
44
+ }), /launch and stop Blender/);
45
+ await assert.rejects(before(h, 'ses_unregistered', 'bash', {
46
+ command: 'echo ready; C:\\Program Files\\Blender Foundation\\Blender\\blender.exe --background',
47
+ }), /launch and stop Blender/);
48
+ await assert.rejects(before(h, 'ses_unregistered', 'bash', {
49
+ command: 'C:\\Program Files\\Blender Foundation\\Blender\\blender.exe --background --version',
50
+ }), /launch and stop Blender/);
51
+ await assert.doesNotReject(before(h, 'ses_unregistered', 'bash', {
52
+ command: 'C:\\Program Files\\Blender Foundation\\Blender\\blender.exe --version',
53
+ }));
54
+ await approveShell(h, 'ses_unregistered', 'git commit -m "docs: Blender/harness notes"');
55
+ await assert.rejects(before(h, 'ses_unregistered', 'bash', {
56
+ command: 'git commit -am "docs: Blender harness notes"',
57
+ }), /bootstrap-commit/);
58
+ });
59
+
60
+ test('capabilities expose the harness-owned operation and bootstrap lists', async (t) => {
61
+ const f = fixture(t), h = hooks(f);
62
+ const result = JSON.parse(await h.tool.blender_workflow.execute({ action: 'capabilities', payload: '{}' }, { sessionID: f.session }));
63
+ assert.ok(result.operations.includes('create_mesh'));
64
+ assert.ok(result.operations.includes('import_asset'));
65
+ assert.ok(result.operations.includes('export_scene'));
66
+ assert.deepEqual([...result.operations].sort(), [...OPERATION_NAMES].sort());
67
+ assert.ok(result.actions.some((action) => action.name === 'intake'));
68
+ assert.ok(result.actions.some((action) => action.name === 'download'));
69
+ assert.ok(result.primitives.includes('cube'));
70
+ assert.ok(result.bootstrapDocuments.includes('Documents/Goals/<NN>-<slug>.md'));
71
+ assert.ok(result.bootstrapDocuments.includes('Documents/TASKSTRACKER.md'));
72
+ assert.ok(result.bootstrapDocuments.includes('Documents/INTAKE.md'));
73
+ assert.ok(result.documentFormats.some((format) => format.path === 'Documents/TASKSTRACKER.md'));
74
+ assert.equal(result.bootstrapDocuments.includes('Documents/TASKS.md'), false);
75
+ assert.equal(result.documentFormats.some((format) => format.path === 'Documents/TASKS.md'), false);
76
+ assert.ok(result.documentFormats.some((format) => format.id === 'goal' && format.sections.includes('Acceptance')));
77
+ assert.ok(result.actions.some((action) => action.name === 'bootstrap-commit'));
78
+ assert.ok(result.actions.some((action) => action.name === 'reregister'));
79
+ assert.ok(result.actions.some((action) => action.name.includes('grader-fail')));
80
+ assert.match(result.commitPolicy, /manual project git/i);
81
+ assert.match(result.documentPolicy, /structured/);
82
+ assert.equal(result.limits.appliesBeforeCheckpoint, 20);
83
+ assert.equal(result.limits.hardApplyCap, 20);
84
+ assert.equal(result.limits.minutesWithoutApply, 15);
85
+ assert.equal(result.limits.batchOperations, 10);
86
+ });
87
+
88
+ test('document ledger returns harness-owned task rows without tokens', async (t) => {
89
+ const f = fixture(t), h = hooks(f); f.begin();
90
+ const ticket = f.harness.issue(f.session, 'apply', { operation: 'delete_object', name: 'Cube' });
91
+ const result = JSON.parse(await h.tool.blender_document.execute(
92
+ { action: 'ledger', payload: '{}' },
93
+ { sessionID: f.session },
94
+ ));
95
+ assert.equal(result.tasks.length, 1);
96
+ assert.equal(result.tasks[0].id, 'T001');
97
+ assert.equal(result.tasks[0].state, 'issued');
98
+ assert.equal(result.tasks[0].token, undefined);
99
+ assert.equal(result.tasks[0].tokenHash.length, 12);
100
+ assert.ok(result.tasks[0].id);
101
+ assert.notEqual(ticket.token, result.tasks[0].tokenHash);
102
+ });
103
+
104
+ test('ticket accepts apply batches up to ten operations and rejects more', async (t) => {
105
+ const f = fixture(t), h = hooks(f); f.begin();
106
+ const operation = { operation: 'delete_object', name: 'Cube' };
107
+ const issued = JSON.parse(await h.tool.blender_workflow.execute(
108
+ { action: 'ticket', payload: JSON.stringify({ operation: 'apply', payload: { operations: [operation, operation] } }) },
109
+ { sessionID: f.session },
110
+ ));
111
+ assert.equal(issued.operation, 'apply');
112
+ await assert.rejects(
113
+ h.tool.blender_workflow.execute(
114
+ { action: 'ticket', payload: JSON.stringify({ operation: 'apply', payload: { operations: Array.from({ length: 11 }, () => operation) } }) },
115
+ { sessionID: f.session },
116
+ ),
117
+ /1-10/,
118
+ );
119
+ });
120
+
121
+ test('document tool returns structured project reads', async (t) => {
122
+ const f = fixture(t), h = hooks(f);
123
+ const result = JSON.parse(await h.tool.blender_document.execute(
124
+ { action: 'read', payload: JSON.stringify({ path: 'Documents/REQUIREMENTS.md' }) },
125
+ { sessionID: f.session },
126
+ ));
127
+ assert.equal(result.content, undefined);
128
+ assert.equal(result.fields.Status, 'Confirmed');
129
+ const objectPayload = JSON.parse(await h.tool.blender_document.execute(
130
+ { action: 'read', payload: { path: 'Documents/REQUIREMENTS.md' } },
131
+ { sessionID: f.session },
132
+ ));
133
+ assert.equal(objectPayload.fields.Status, 'Confirmed');
134
+ const workflowObject = JSON.parse(await h.tool.blender_workflow.execute(
135
+ { action: 'status', payload: { project: f.project } },
136
+ { sessionID: f.session },
137
+ ));
138
+ assert.equal(workflowObject.project, f.project);
139
+ await assert.rejects(
140
+ h.tool.blender_workflow.execute({ action: 'status', payload: ['not', 'an', 'object'] }, { sessionID: f.session }),
141
+ /payload/,
142
+ );
143
+ assert.ok(result.sections.some((section) => section.heading === 'Confirmed requirements'));
144
+ const section = JSON.parse(await h.tool.blender_document.execute(
145
+ { action: 'read', payload: JSON.stringify({ path: 'Documents/REQUIREMENTS.md', section: 'Confirmed requirements' }) },
146
+ { sessionID: f.session },
147
+ ));
148
+ assert.match(section.section.content, /Fixture confirmed requirements/);
149
+ f.begin();
150
+ f.harness.transaction((state) => state.projects[f.project].change.paths.push(`${f.project}/Documents/NEW.md`));
151
+ await h.tool.blender_document.execute(
152
+ { action: 'write', payload: JSON.stringify({ path: 'Documents/NEW.md', content: '# New document\n' }) },
153
+ { sessionID: f.session },
154
+ );
155
+ assert.ok(fs.existsSync(path.join(f.root, f.project, 'Documents', 'NEW.md')));
156
+ f.harness.transaction((state) => state.projects[f.project].change.paths.push(`${f.project}/Documents/REQUIREMENTS.md`));
157
+ const requirements = fs.readFileSync(path.join(f.root, f.project, 'Documents', 'REQUIREMENTS.md'), 'utf8');
158
+ await assert.rejects(
159
+ h.tool.blender_document.execute(
160
+ { action: 'write', payload: JSON.stringify({ path: 'Documents/REQUIREMENTS.md', content: requirements }) },
161
+ { sessionID: f.session },
162
+ ),
163
+ /frozen/,
164
+ );
165
+ });
166
+
167
+ test('direct writes cannot bypass frozen documents or review artifacts', async (t) => {
168
+ const f = fixture(t), h = hooks(f); f.begin();
169
+ f.harness.transaction((state) => state.projects[f.project].change.paths.push(
170
+ `${f.project}/Documents/REQUIREMENTS.md`,
171
+ `${f.project}/Documents/Reviews/README.md`,
172
+ ));
173
+ await assert.rejects(before(h, f.session, 'write', {
174
+ filePath: path.join(f.root, f.project, 'Documents', 'REQUIREMENTS.md'),
175
+ content: 'changed\n',
176
+ }), /frozen/);
177
+ await assert.rejects(before(h, f.session, 'write', {
178
+ filePath: path.join(f.root, f.project, 'Documents', 'Reviews', 'README.md'),
179
+ content: 'changed\n',
180
+ }), /review artifacts/);
181
+ });
182
+
183
+ test('production worker delegation requires valid read-only limits', async (t) => {
184
+ const f = fixture(t), h = hooks(f); f.begin();
185
+ const worker = {
186
+ agent: 'blender-worker',
187
+ provider: 'fixture-provider',
188
+ model: 'fixture-model',
189
+ variant: 'fixture-variant',
190
+ backup: { provider: 'backup-provider', model: 'backup-model', variant: 'backup-variant' },
191
+ max_minutes: 10,
192
+ max_steps: 18,
193
+ max_failed_attempts: 2,
194
+ max_parallel: 1,
195
+ };
196
+ f.write('.kilo/blender-project-harness/config.json', JSON.stringify({ blender_subagents: { workers: worker } }));
197
+ f.write('.kilo/agent/blender-worker.md', '---\nsteps: 20\n---\n');
198
+ await before(h, f.session, 'task', { subagent_type: 'blender-worker', prompt: 'Inspect cube', background: false });
199
+ await assert.rejects(
200
+ before(h, f.session, 'task', { subagent_type: 'blender-worker', prompt: 'Inspect cube', background: true }),
201
+ /foreground/,
202
+ );
203
+ f.write('.kilo/blender-project-harness/config.json', JSON.stringify({ blender_subagents: { workers: { ...worker, max_parallel: 2 } } }));
204
+ await assert.rejects(
205
+ before(h, f.session, 'task', { subagent_type: 'blender-worker', prompt: 'Inspect cube', background: false }),
206
+ /max_parallel/,
207
+ );
208
+ f.write('.kilo/blender-project-harness/config.json', JSON.stringify({ blender_subagents: { workers: { ...worker, max_steps: 21 } } }));
209
+ await assert.rejects(
210
+ before(h, f.session, 'task', { subagent_type: 'blender-worker', prompt: 'Inspect cube', background: false }),
211
+ /steps ceiling/,
212
+ );
213
+ });
214
+
215
+ test('grader delegation requires the exact ticket evidence key and scene hash', async (t) => {
216
+ const f = fixture(t, { review: 'Single' }), h = hooks(f); f.begin();
217
+ const token = '11111111-1111-1111-1111-111111111111';
218
+ f.harness.transaction((s) => {
219
+ const change = s.projects[f.project].change;
220
+ change.inspected = true;
221
+ change.graders.visual = { token };
222
+ });
223
+ await assert.rejects(
224
+ before(h, f.session, 'task', { subagent_type: 'blender-grader', prompt: `HARNESS_GRADER:${token}\nhand-written prompt without evidence`, background: false }),
225
+ /saved scene SHA-256/,
226
+ );
227
+ });
228
+
229
+ test('manual creation of new project documents is blocked', async (t) => {
230
+ const f = fixture(t), h = hooks(f); f.begin();
231
+ f.harness.transaction((s) => s.projects[f.project].change.paths.push(`${f.project}/Documents/NEW.md`));
232
+ await assert.rejects(
233
+ before(h, f.session, 'write', { filePath: `${f.project}/Documents/NEW.md`, content: '# New\n' }),
234
+ /blender_document/,
235
+ );
236
+ });
237
+
238
+ test('worker children stay read-only after delegation', async (t) => {
239
+ const f = fixture(t);
240
+ f.begin();
241
+ const h = createHooks(f.harness, {
242
+ session: {
243
+ get: async ({ path: p }) => ({ data: { id: p.id, parentID: f.session, agent: 'blender-worker' } }),
244
+ messages: async () => ({ data: [
245
+ { info: { role: 'user', agent: 'blender-worker' }, parts: [{ type: 'text', text: 'Inspect scene' }] },
246
+ { info: { role: 'assistant', providerID: 'fixture-provider', modelID: 'fixture-model', variant: 'fixture-variant' }, parts: [] },
247
+ ] }),
248
+ },
249
+ });
250
+ await before(h, 'ses_worker', 'read', { filePath: path.join(f.root, f.project, 'README.md') });
251
+ await before(h, 'ses_worker', 'blender_get_scene_info', {});
252
+ await assert.rejects(
253
+ before(h, 'ses_worker', 'write', { filePath: path.join(f.root, f.project, 'README.md'), content: 'edited' }),
254
+ /read-only/,
255
+ );
256
+ await assert.rejects(before(h, 'ses_worker', 'blender_workflow', { action: 'status' }), /read-only/);
257
+ await assert.rejects(
258
+ before(h, 'ses_worker', 'blender_harness_execute', { action: 'status' }),
259
+ /assigned apply operations only/,
260
+ );
261
+ const wrong = createHooks(f.harness, {
262
+ session: {
263
+ get: async ({ path: p }) => ({ data: { id: p.id, parentID: f.session, agent: 'blender-worker' } }),
264
+ messages: async () => ({ data: [
265
+ { info: { role: 'user', agent: 'blender-worker' }, parts: [{ type: 'text', text: 'Inspect scene' }] },
266
+ { info: { role: 'assistant', providerID: 'other-provider', modelID: 'fixture-model', variant: 'fixture-variant' }, parts: [] },
267
+ ] }),
268
+ },
269
+ });
270
+ await assert.rejects(before(wrong, 'ses_wrong_worker', 'blender_get_scene_info', {}), /configured worker/);
271
+ });
272
+
273
+ test('apply execution is delegated to worker children', async (t) => {
274
+ const f = fixture(t), h = hooks(f); f.begin();
275
+ const ticket = f.harness.issue(f.session, 'apply', { retry: true });
276
+ await assert.rejects(
277
+ before(h, f.session, 'blender_harness_execute', ticket),
278
+ /delegates apply operations/,
279
+ );
280
+ const worker = createHooks(f.harness, {
281
+ session: {
282
+ get: async ({ path: p }) => ({ data: { id: p.id, parentID: f.session, agent: 'blender-worker' } }),
283
+ messages: async () => ({ data: [
284
+ { info: { role: 'user', agent: 'blender-worker' }, parts: [{ type: 'text', text: 'Apply assignment' }] },
285
+ { info: { role: 'assistant', providerID: 'fixture-provider', modelID: 'fixture-model', variant: 'fixture-variant' }, parts: [] },
286
+ ] }),
287
+ },
288
+ });
289
+ await before(worker, 'ses_worker', 'blender_harness_execute', ticket);
290
+ assert.equal(f.harness.transaction((s) => s.tickets[ticket.token].state), 'authorized');
291
+ });
292
+
293
+ test('plugin allows recovery reads but rejects unscoped file writes', async (t) => {
294
+ const f = fixture(t), h = hooks(f); f.begin();
295
+ await before(h, f.session, 'read', { filePath: path.join(f.root, f.project, 'README.md') });
296
+ await before(h, f.session, 'write', { filePath: path.join(f.root, '.temp', 'Scratch', 'note.md'), content: 'workspace note' });
297
+ await before(h, f.session, 'write', { filePath: path.join(f.root, f.project, 'Documents', 'DECISIONS.md'), content: 'new docs' });
298
+ await assert.rejects(before(h, f.session, 'write', { filePath: path.join(f.root, f.scene), content: 'forged' }), /runtime-owned/);
299
+ await assert.rejects(before(h, f.session, 'apply_patch', { patchText: '*** Begin Patch\n*** Add File: tools/bypass.py\n+pass\n*** End Patch' }), /scope/);
300
+ });
301
+
302
+ test('unregistered sessions cannot write project files or run mutating shell commands', async (t) => {
303
+ const f = fixture(t), h = hooks(f);
304
+ await assert.rejects(before(h, 'unregistered', 'write', { filePath: path.join(f.root, f.project, 'README.md'), content: 'bypass' }), /harness-owned/);
305
+ await before(h, 'unregistered', 'write', { filePath: path.join(f.root, 'Blender', 'README.md'), content: 'collection docs' });
306
+ await assert.rejects(before(h, 'unregistered', 'bash', { command: '.\\tools\\start-blender.cmd "Blender\\Test Project"' }), /retired/);
307
+ await assert.rejects(before(h, 'unregistered', 'bash', { command: `git add -- "${f.project}/README.md"` }), /bootstrap-commit/);
308
+ await assert.rejects(before(h, 'unregistered', 'bash', { command: 'git add -- .' }), /bootstrap-commit/);
309
+ await assert.rejects(before(h, 'unregistered', 'bash', { command: 'Set-Content "Blender\\Test Project\\README.md" "bypass"' }), /project files/);
310
+ await assert.rejects(before(h, 'unregistered', 'bash', { command: 'node -e "process.exit(1)"' }), /Shell approval required/);
311
+ await assert.doesNotReject(before(h, 'unregistered', 'bash', { command: 'git status --short; echo bypass' }));
312
+ await before(h, 'unregistered', 'bash', { command: 'git status --short' });
313
+ await before(h, 'unregistered', 'bash', { command: 'node .kilo/blender-project-harness/tools/harness/cli.mjs scaffold --name "Fresh Project"' });
314
+ await before(h, 'unregistered', 'bash', { command: 'git ls-tree -r --name-only HEAD' });
315
+ await before(h, 'unregistered', 'bash', { command: 'git log --oneline -8' });
316
+ await before(h, 'unregistered', 'bash', { command: 'Get-ChildItem .kilo/blender-project-harness/tools/harness' });
317
+ await before(h, 'unregistered', 'bash', { command: 'Get-Content .kilo/blender-project-harness/tools/harness/plugin.mjs | Select-String safe' });
318
+ await before(h, 'unregistered', 'bash', { command: 'rg -n "safeShellSegment" .kilo/blender-project-harness/tools/harness' });
319
+ const approval = await approveShell(h, 'unregistered', 'git switch Test_Punal_12', 'Switch branches without discarding the preserved live-test evidence.');
320
+ assert.ok(fs.existsSync(path.join(f.root, 'Commands', `${approval}.md`)));
321
+ assert.match(fs.readFileSync(path.join(f.root, 'Commands', `${approval}.md`), 'utf8'), /Switch branches without discarding/);
322
+ assert.match(git(f.root, ['log', '-1', '--format=%s']), /shell approval/);
323
+ });
324
+
325
+ test('production read-only shell works and other workspace commands need confirmation', async (t) => {
326
+ const f = fixture(t), h = hooks(f); f.begin();
327
+ await assert.doesNotReject(before(h, f.session, 'bash', { command: 'git log --oneline -8' }));
328
+ await assert.doesNotReject(before(h, f.session, 'bash', { command: 'Get-Content .kilo/blender-project-harness/tools/harness/plugin.mjs | Select-String safe' }));
329
+ await assert.rejects(before(h, f.session, 'bash', { command: 'npm install' }), /Shell approval required/);
330
+ const approval = await approveShell(h, f.session, 'npm install', 'Run the locked dependency install for workspace maintenance.');
331
+ assert.ok(fs.existsSync(path.join(f.root, f.project, 'Documents', 'Commands', `${approval}.md`)));
332
+ assert.match(git(f.root, ['log', '-1', '--format=%s']), /shell approval/);
333
+ assert.equal(git(f.root, ['status', '--porcelain', '--', `${f.project}/Documents/Commands`]).trim(), '');
334
+ await assert.rejects(before(h, f.session, 'bash', { command: 'git add -- .' }), /blender_workflow commit/);
335
+ });
336
+
337
+ test('unregistered sessions must create and commit scaffold documents through the harness', async (t) => {
338
+ const f = fixture(t), h = hooks(f);
339
+ const fresh = 'Blender/New Project';
340
+ const freshRoot = path.join(f.root, fresh);
341
+ fs.cpSync(path.join(f.root, f.project), freshRoot, { recursive: true });
342
+ fs.rmSync(path.join(freshRoot, 'scene.blend'));
343
+ await assert.rejects(
344
+ before(h, 'unregistered', 'write', { filePath: path.join(freshRoot, 'README.md'), content: 'bypass' }),
345
+ /harness-owned/,
346
+ );
347
+ await assert.rejects(
348
+ h.tool.blender_workflow.execute(
349
+ { action: 'bootstrap-document', payload: JSON.stringify({ project: fresh, path: 'Documents/References/note.md', content: 'No heading\n' }) },
350
+ { sessionID: 'unregistered' },
351
+ ),
352
+ /level-1 heading/,
353
+ );
354
+ await h.tool.blender_workflow.execute(
355
+ { action: 'bootstrap-document', payload: JSON.stringify({ project: fresh, path: 'Documents/References/note.md', content: '# Reference note\n' }) },
356
+ { sessionID: 'unregistered' },
357
+ );
358
+ const committed = JSON.parse(await h.tool.blender_workflow.execute(
359
+ { action: 'bootstrap-commit', payload: JSON.stringify({ project: fresh, message: 'docs: scaffold new project' }) },
360
+ { sessionID: 'unregistered' },
361
+ ));
362
+ assert.match(committed.next, /prepare/);
363
+ const status = JSON.parse(await h.tool.blender_workflow.execute(
364
+ { action: 'status', payload: JSON.stringify({ project: fresh }) },
365
+ { sessionID: 'unregistered' },
366
+ ));
367
+ assert.match(status.nextStep.command, /prepare/);
368
+ });
369
+
370
+ test('launch requires an active goal and shell Blender commands are retired', async (t) => {
371
+ const f = fixture(t), h = hooks(f);
372
+ await assert.rejects(h.tool.blender_workflow.execute({ action: 'launch', payload: '{}' }, { sessionID: f.session }), /active goal/);
373
+ await assert.rejects(before(h, f.session, 'bash', { command: 'blender.exe scene.blend' }), /retired/);
374
+ });
375
+
376
+ test('bootstrap save can be staged and committed without review', async (t) => {
377
+ const f = fixture(t), h = hooks(f);
378
+ f.begin();
379
+ f.harness.transaction((s) => {
380
+ const change = s.projects[f.project].change;
381
+ change.bootstrap = true;
382
+ change.saved = true;
383
+ change.operations = 0;
384
+ });
385
+ fs.appendFileSync(path.join(f.root, f.scene), 'bootstrap save\n');
386
+ git(f.root, ['add', '--', f.scene]);
387
+ await assert.rejects(
388
+ before(h, f.session, 'bash', { command: `git add -- "${f.scene}"` }),
389
+ /blender_workflow commit/,
390
+ );
391
+ await assert.rejects(
392
+ before(h, f.session, 'bash', { command: 'git commit -m "feat: bootstrap scene"' }),
393
+ /blender_workflow commit/,
394
+ );
395
+ git(f.root, ['reset', '--', f.scene]);
396
+ await h.tool.blender_workflow.execute(
397
+ { action: 'commit', payload: JSON.stringify({ message: 'feat: bootstrap scene' }) },
398
+ { sessionID: f.session },
399
+ );
400
+ await before(h, f.session, 'goal_report', { status: 'blocked', reason: 'waiting' });
401
+ });
402
+
403
+ test('registered workspace code commits are allowed through shell approval', async (t) => {
404
+ const f = fixture(t), h = hooks(f); f.begin();
405
+ const code = path.join(f.root, 'tools', 'harness', 'scratch.txt');
406
+ fs.mkdirSync(path.dirname(code), { recursive: true });
407
+ fs.writeFileSync(code, 'scratch\n');
408
+ git(f.root, ['add', '--', '.kilo/blender-project-harness/tools/harness/scratch.txt']);
409
+ await approveShell(h, f.session, 'git commit -m "chore: scratch workspace file"');
410
+ });
411
+
412
+ test('plugin permits matching ticket once, blocks cross-session ticket', async (t) => {
413
+ const f = fixture(t), h = hooks(f); f.begin();
414
+ const ticket = f.harness.issue(f.session, 'save', {});
415
+ await assert.rejects(before(h, 'ses_other', 'blender_harness_execute', ticket), /forged/);
416
+ await before(h, f.session, 'blender_harness_execute', ticket);
417
+ await assert.rejects(before(h, f.session, 'blender_harness_execute', ticket), /replayed/);
418
+ });
419
+
420
+ test('blocked goal report does not require a commit', async (t) => {
421
+ const f = fixture(t), h = hooks(f); f.begin();
422
+ fs.appendFileSync(path.join(f.root, f.project, 'README.md'), 'uncommitted work\n');
423
+ await before(h, f.session, 'goal_report', { status: 'blocked', reason: 'waiting' });
424
+ });
425
+
426
+ test('Kilo goal state overrides status and requires resume before reporting', async (t) => {
427
+ const f = fixture(t);
428
+ const h = hooks(f, { 'kilo.goal': { status: 'blocked', reason: 'A rejected tool call blocked the goal.' } });
429
+ f.begin();
430
+ const status = JSON.parse(await h.tool.blender_workflow.execute({ action: 'status', payload: {} }, { sessionID: f.session }));
431
+ assert.equal(status.kiloGoal.status, 'blocked');
432
+ assert.equal(status.nextStep.phase, 'resume-goal');
433
+ assert.match(status.nextStep.command, /goal \{action:"resume"\}/);
434
+ await assert.rejects(
435
+ before(h, f.session, 'goal_report', { status: 'blocked', reason: 'waiting' }),
436
+ /goal \{action:"resume"\}/,
437
+ );
438
+ });
439
+
440
+ test('active Kilo goal reconciles the harness without resume', async (t) => {
441
+ const f = fixture(t);
442
+ const h = hooks(f, { 'kilo.goal': { status: 'active' } });
443
+ f.begin();
444
+ f.harness.transaction((s) => { s.projects[f.project].goalActive = false; });
445
+ const status = JSON.parse(await h.tool.blender_workflow.execute({ action: 'status', payload: {} }, { sessionID: f.session }));
446
+ assert.equal(status.goalActive, true);
447
+ assert.notEqual(status.nextStep.phase, 'resume-goal');
448
+ });
449
+
450
+ test('recover reopens a failed change through the tool', async (t) => {
451
+ const f = fixture(t), h = hooks(f); f.begin();
452
+ f.harness.transaction((state) => {
453
+ const change = state.projects[f.project].change;
454
+ change.failure = 'Cannot render, no camera';
455
+ change.deadline = 0;
456
+ });
457
+ const result = JSON.parse(await h.tool.blender_workflow.execute(
458
+ { action: 'recover', payload: JSON.stringify({ reopen: true }) },
459
+ { sessionID: f.session },
460
+ ));
461
+ assert.equal(result.change.failure, false);
462
+ assert.equal(result.change.reopen, true);
463
+ assert.equal(result.nextStep.phase, 'apply');
464
+ });
465
+
466
+ test('plugin blocks early completion, fake internal commands and wrong goal', async (t) => {
467
+ const f = fixture(t), h = hooks(f);
468
+ await assert.rejects(before(h, f.session, 'goal_report', { status: 'complete' }), /completion blocked/);
469
+ await assert.rejects(before(h, f.session, 'goal', { action: 'start', objective: 'fake' }), /objective/);
470
+ await assert.rejects(h.tool.blender_workflow.execute({ action: 'goal-active', payload: '{}' }, { sessionID: f.session }), /internal/);
471
+ await before(h, f.session, 'goal', { action: 'start', objective: f.objective.replace(/\n/g, '\r\n') + '\r\n' });
472
+ });
473
+
474
+ test('plugin injects compact durable recovery context', async (t) => {
475
+ const f = fixture(t), h = hooks(f); f.begin();
476
+ const output = { context: [] };
477
+ await h['experimental.session.compacting']({ sessionID: f.session }, output);
478
+ assert.match(output.context[0], /Create fixture composition/);
479
+ assert.match(output.context[0], /do not repeat goal start/);
480
+ });
481
+
482
+ test('development mode bypasses all workflow hooks', async (t) => {
483
+ const f = fixture(t), h = hooks(f); f.begin();
484
+ const configPath = path.join(f.root, '.kilo', 'blender-project-harness', 'config.json');
485
+ const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
486
+ config.development_mode = true;
487
+ fs.writeFileSync(configPath, JSON.stringify(config));
488
+
489
+ await assert.doesNotReject(before(h, f.session, 'write', {
490
+ filePath: path.join(f.root, 'tools', 'harness', 'dev-note.txt'),
491
+ content: 'development note\n',
492
+ }));
493
+ await assert.doesNotReject(before(h, f.session, 'bash', { command: 'git branch -a -vv' }));
494
+ await assert.doesNotReject(before(h, f.session, 'blender_execute_blender_code', { code: 'print(1)' }));
495
+
496
+ const transformed = { system: [] };
497
+ await h['experimental.chat.system.transform']({ sessionID: f.session }, transformed);
498
+ assert.deepEqual(transformed.system, []);
499
+
500
+ const after = { output: 'unchanged' };
501
+ await h['tool.execute.after']({ sessionID: f.session, callID: 'dev', tool: 'bash', args: { command: 'git branch' } }, after);
502
+ assert.equal(after.output, 'unchanged');
503
+ });
504
+
505
+ test('development mode can whitelist specific sessions', async (t) => {
506
+ const f = fixture(t), h = hooks(f);
507
+ const configPath = path.join(f.root, '.kilo', 'blender-project-harness', 'config.json');
508
+ const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
509
+ config.development_mode = false;
510
+ config.development_sessions = [f.session];
511
+ fs.writeFileSync(configPath, JSON.stringify(config));
512
+
513
+ const note = { filePath: path.join(f.root, f.project, 'Documents', 'DECISIONS.md'), content: 'session development note\n' };
514
+ await assert.doesNotReject(before(h, f.session, 'write', note));
515
+ await assert.doesNotReject(before(h, f.session, 'bash', { command: 'npm install' }));
516
+ await assert.rejects(before(h, 'ses_other', 'write', note), /project files are harness-owned/);
517
+ await assert.rejects(before(h, 'ses_other', 'bash', { command: 'npm install' }), /Shell approval required/);
518
+
519
+ delete config.development_mode;
520
+ fs.writeFileSync(configPath, JSON.stringify(config));
521
+ await assert.doesNotReject(before(h, f.session, 'write', note));
522
+
523
+ delete config.development_sessions;
524
+ fs.writeFileSync(configPath, JSON.stringify(config));
525
+ await assert.rejects(before(h, f.session, 'write', note), /no open bounded change/);
526
+ });