@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.
- package/.kilo/agent/blender-grader.md +197 -0
- package/.kilo/agent/blender-worker.md +127 -0
- package/.kilo/blender-project-harness/config.example.json +71 -0
- package/.kilo/blender-project-harness/tools/harness/EXPECTED_FLOW.md +656 -0
- package/.kilo/blender-project-harness/tools/harness/blender-operations.mjs +882 -0
- package/.kilo/blender-project-harness/tools/harness/blender-operations.test.mjs +550 -0
- package/.kilo/blender-project-harness/tools/harness/blender.mjs +301 -0
- package/.kilo/blender-project-harness/tools/harness/blender.test.mjs +87 -0
- package/.kilo/blender-project-harness/tools/harness/cli.mjs +127 -0
- package/.kilo/blender-project-harness/tools/harness/documents.mjs +201 -0
- package/.kilo/blender-project-harness/tools/harness/download.mjs +94 -0
- package/.kilo/blender-project-harness/tools/harness/download.test.mjs +63 -0
- package/.kilo/blender-project-harness/tools/harness/engine.mjs +1866 -0
- package/.kilo/blender-project-harness/tools/harness/engine.test.mjs +2219 -0
- package/.kilo/blender-project-harness/tools/harness/intake.mjs +571 -0
- package/.kilo/blender-project-harness/tools/harness/intake.test.mjs +181 -0
- package/.kilo/blender-project-harness/tools/harness/package-lock.json +1227 -0
- package/.kilo/blender-project-harness/tools/harness/package.json +15 -0
- package/.kilo/blender-project-harness/tools/harness/plugin.mjs +550 -0
- package/.kilo/blender-project-harness/tools/harness/plugin.test.mjs +526 -0
- package/.kilo/blender-project-harness/tools/harness/proxy.mjs +222 -0
- package/.kilo/blender-project-harness/tools/harness/proxy.test.mjs +217 -0
- package/.kilo/blender-project-harness/tools/harness/scaffold.mjs +83 -0
- package/.kilo/blender-project-harness/tools/harness/scaffold.test.mjs +74 -0
- package/.kilo/blender-project-harness/tools/harness/server.mjs +24 -0
- package/.kilo/blender-project-harness/tools/harness/shell-policy.mjs +196 -0
- package/.kilo/blender-project-harness/tools/harness/shell-policy.test.mjs +68 -0
- package/.kilo/blender-project-harness/tools/harness/templates/project/Documents/ASSETS.md +29 -0
- package/.kilo/blender-project-harness/tools/harness/templates/project/Documents/DECISIONS.md +8 -0
- package/.kilo/blender-project-harness/tools/harness/templates/project/Documents/Goals/01-project-brief.md +71 -0
- package/.kilo/blender-project-harness/tools/harness/templates/project/Documents/Goals/README.md +17 -0
- package/.kilo/blender-project-harness/tools/harness/templates/project/Documents/INTAKE.md +8 -0
- package/.kilo/blender-project-harness/tools/harness/templates/project/Documents/README.md +57 -0
- package/.kilo/blender-project-harness/tools/harness/templates/project/Documents/REQUIREMENTS.md +64 -0
- package/.kilo/blender-project-harness/tools/harness/templates/project/Documents/REVIEW.md +29 -0
- package/.kilo/blender-project-harness/tools/harness/templates/project/Documents/References/README.md +18 -0
- package/.kilo/blender-project-harness/tools/harness/templates/project/Documents/Reviews/README.md +48 -0
- package/.kilo/blender-project-harness/tools/harness/templates/project/Documents/TASKSTRACKER.md +10 -0
- package/.kilo/blender-project-harness/tools/harness/templates/project/Documents/Tasks/README.md +4 -0
- package/.kilo/blender-project-harness/tools/harness/templates/project/Documents/WORKFLOW.md +36 -0
- package/.kilo/blender-project-harness/tools/harness/templates/project/README.md +130 -0
- package/.kilo/blender-project-harness/tools/harness/templates/project/assets/README.md +31 -0
- package/.kilo/blender-project-harness/tools/harness/templates/project/assets/audio/.gitkeep +0 -0
- package/.kilo/blender-project-harness/tools/harness/templates/project/assets/external/README.md +43 -0
- package/.kilo/blender-project-harness/tools/harness/templates/project/assets/external/SOURCE_TEMPLATE.md +18 -0
- package/.kilo/blender-project-harness/tools/harness/templates/project/assets/hdri/.gitkeep +0 -0
- package/.kilo/blender-project-harness/tools/harness/templates/project/assets/models/.gitkeep +0 -0
- package/.kilo/blender-project-harness/tools/harness/templates/project/assets/textures/.gitkeep +0 -0
- package/.kilo/blender-project-harness/tools/harness/templates/project/backups/.gitkeep +0 -0
- package/.kilo/blender-project-harness/tools/harness/templates/project/backups/README.md +5 -0
- package/.kilo/blender-project-harness/tools/harness/templates/project/exports/.gitkeep +0 -0
- package/.kilo/blender-project-harness/tools/harness/templates/project/exports/README.md +11 -0
- package/.kilo/blender-project-harness/tools/harness/templates/project/renders/README.md +7 -0
- package/.kilo/blender-project-harness/tools/harness/templates/project/renders/final/.gitkeep +0 -0
- package/.kilo/blender-project-harness/tools/harness/templates/project/renders/wip/.gitkeep +0 -0
- package/.kilo/blender-project-harness/tools/harness/templates/project/scenes/README.md +10 -0
- package/.kilo/blender-project-harness/tools/harness/templates/project/scripts/README.md +16 -0
- package/.kilo/blender-project-harness/tools/harness/test-fixture.mjs +91 -0
- package/.kilo/blender-project-harness/tools/harness/workspace.mjs +30 -0
- package/.kilo/blender-project-harness/tools/harness/workspace.test.mjs +31 -0
- package/.kilo/blender-project-harness/tools/test_blender_tools.py +427 -0
- package/.kilo/plugins/blender-workflow.js +3 -0
- package/.kilo/skills/blender-assets/SKILL.md +99 -0
- package/.kilo/skills/blender-mcp/SKILL.md +147 -0
- package/.kilo/skills/blender-mcp/references/README.md +22 -0
- package/.kilo/skills/blender-mcp/references/animation.md +83 -0
- package/.kilo/skills/blender-mcp/references/bpy.md +97 -0
- package/.kilo/skills/blender-mcp/references/level-design.md +53 -0
- package/.kilo/skills/blender-mcp/references/materials.md +80 -0
- package/.kilo/skills/blender-mcp/references/retopology.md +104 -0
- package/.kilo/skills/blender-mcp/references/rigging.md +123 -0
- package/.kilo/skills/blender-mcp/references/scene.md +72 -0
- package/.kilo/skills/blender-project-goal/SKILL.md +115 -0
- package/.kilo/skills/blender-project-grading/SKILL.md +184 -0
- package/.kilo/skills/blender-project-readme/SKILL.md +46 -0
- package/.kilo/skills/blender-project-requirements/SKILL.md +84 -0
- package/.kilo/skills/blender-project-scaffold/SKILL.md +59 -0
- package/.kilo/skills/blender-project-workflow/SKILL.md +96 -0
- package/.kilo/skills/blender-subagents/SKILL.md +204 -0
- package/.kilo/skills/blender-workflow-harness/SKILL.md +187 -0
- package/AGENTS.md +234 -0
- package/Blender/README.md +30 -0
- package/CHANGELOG.md +22 -0
- package/LICENSE +21 -0
- package/README.md +368 -2
- package/THIRD_PARTY_NOTICES.md +51 -0
- package/bin/blender-project-harness.mjs +4 -0
- package/installer/cli.mjs +116 -0
- package/installer/install.mjs +325 -0
- package/installer/install.test.mjs +134 -0
- package/package.json +58 -6
|
@@ -0,0 +1,1866 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import crypto from 'node:crypto';
|
|
5
|
+
import { spawnSync } from 'node:child_process';
|
|
6
|
+
import { instanceStatus, launchProject, stopProject } from './blender.mjs';
|
|
7
|
+
import { describeDocumentFormats, documentOutline, documentSection, isBootstrapDocument, isBootstrapFile, validateDocument } from './documents.mjs';
|
|
8
|
+
import {
|
|
9
|
+
appendAssetRow,
|
|
10
|
+
appendIntakeRow,
|
|
11
|
+
appendReferenceRow,
|
|
12
|
+
copyIntake,
|
|
13
|
+
createIntakeDocument,
|
|
14
|
+
planIntake,
|
|
15
|
+
referenceSourceDocument,
|
|
16
|
+
sourceDocument,
|
|
17
|
+
} from './intake.mjs';
|
|
18
|
+
import { downloadToStaging } from './download.mjs';
|
|
19
|
+
import { configPath } from './workspace.mjs';
|
|
20
|
+
|
|
21
|
+
export const digest = (data) => crypto.createHash('sha256').update(data).digest('hex');
|
|
22
|
+
export const normalizeGoal = (value) => typeof value === 'string' ? value.replace(/\r\n?/g, '\n').trim() : value;
|
|
23
|
+
const CHECKPOINT_EVERY = 20;
|
|
24
|
+
const ASSET_USING_OPERATIONS = new Set(['import_asset', 'link_library', 'import_audio', 'image_reference', 'image_texture', 'world_hdri', 'pack_assets']);
|
|
25
|
+
const documentOnlyPath = (name) => /\.md$/i.test(name) || /(?:^|\/)scripts\/[^/]+\.(?:py|pyw)$/i.test(name);
|
|
26
|
+
const nonScenePath = (name) => documentOnlyPath(name) || /(?:^|\/)(?:assets|Documents\/References)\//i.test(name);
|
|
27
|
+
const checkAssetsStrict = (payload) => {
|
|
28
|
+
const operations = Array.isArray(payload?.operations) ? payload.operations : [payload];
|
|
29
|
+
return operations.some((operation) => operation?.operation === 'check_assets' && operation.strict === true);
|
|
30
|
+
};
|
|
31
|
+
const CHANGE_WINDOW_MS = 900000;
|
|
32
|
+
const TICKET_TTL_MS = 10 * 60000;
|
|
33
|
+
const MAX_CHANGE_OBJECTS = 20;
|
|
34
|
+
const MAX_CHANGE_SCENES = 32;
|
|
35
|
+
const MAX_DOWNLOADS_PER_PROJECT = 20;
|
|
36
|
+
const TASKSTRACKER_DOCUMENT = 'Documents/TASKSTRACKER.md';
|
|
37
|
+
const validSceneName = (name) => typeof name === 'string' && /^[\x20-\x7e]{1,100}$/.test(name) && name.trim().length > 0;
|
|
38
|
+
const isPlainObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
39
|
+
&& (Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null);
|
|
40
|
+
const sceneNamesFromOperations = (payload) => {
|
|
41
|
+
const operations = Array.isArray(payload?.operations) ? payload.operations : [payload];
|
|
42
|
+
const names = [];
|
|
43
|
+
for (const operation of operations) {
|
|
44
|
+
if (!operation || typeof operation !== 'object') continue;
|
|
45
|
+
if (validSceneName(operation.scene)) names.push(operation.scene);
|
|
46
|
+
if (['create_scene', 'duplicate_scene'].includes(operation.operation) && validSceneName(operation.name)) names.push(operation.name);
|
|
47
|
+
if (['switch_scene', 'delete_scene'].includes(operation.operation) && validSceneName(operation.name)) names.push(operation.name);
|
|
48
|
+
}
|
|
49
|
+
return [...new Set(names)];
|
|
50
|
+
};
|
|
51
|
+
const EXTERNAL_BINARY_EXTENSIONS = new Set(['.glb', '.gltf', '.fbx', '.obj', '.usd', '.usdz', '.blend', '.png', '.jpg', '.jpeg', '.webp', '.tif', '.tiff', '.bmp', '.hdr', '.exr', '.wav', '.mp3', '.ogg', '.flac', '.zip', '.7z', '.rar']);
|
|
52
|
+
export const externalBinaryPolicy = (root, project, names) => {
|
|
53
|
+
for (const name of names) {
|
|
54
|
+
const relative = name.startsWith(`${project}/`) ? name.slice(project.length + 1) : name;
|
|
55
|
+
if (!relative.startsWith('assets/external/')) continue;
|
|
56
|
+
if (!EXTERNAL_BINARY_EXTENSIONS.has(path.extname(relative).toLowerCase())) continue;
|
|
57
|
+
let directory = path.dirname(path.join(root, ...name.split('/')));
|
|
58
|
+
let source = null;
|
|
59
|
+
while (directory.startsWith(path.join(root, ...project.split('/'), 'assets', 'external'))) {
|
|
60
|
+
const candidate = path.join(directory, 'SOURCE.md');
|
|
61
|
+
if (fs.existsSync(candidate)) { source = fs.readFileSync(candidate, 'utf8'); break; }
|
|
62
|
+
const parent = path.dirname(directory);
|
|
63
|
+
if (parent === directory) break;
|
|
64
|
+
directory = parent;
|
|
65
|
+
}
|
|
66
|
+
if (!source || !/Redistribution allowed:\s*yes\b/i.test(source)) {
|
|
67
|
+
deny(`external binary is not cleared for commit: ${name}`);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
const sceneInspectionsComplete = (change) => {
|
|
72
|
+
const renders = change?.renders ?? {};
|
|
73
|
+
const names = Object.keys(renders);
|
|
74
|
+
if (!names.length) return Boolean(change?.inspected);
|
|
75
|
+
return names.every((name) => {
|
|
76
|
+
const inspection = change?.inspections?.[name];
|
|
77
|
+
return inspection && inspection.hash === change.evidence?.[renders[name]];
|
|
78
|
+
});
|
|
79
|
+
};
|
|
80
|
+
const canonical = (value) => Array.isArray(value) ? value.map(canonical) : value && typeof value === 'object'
|
|
81
|
+
? Object.fromEntries(Object.keys(value).sort().map((key) => [key, canonical(value[key])])) : value;
|
|
82
|
+
export const stable = (value) => JSON.stringify(canonical(value));
|
|
83
|
+
export const deny = (message) => {
|
|
84
|
+
throw new Error(`Workflow gate: ${message}. Next: call blender_workflow {action:"status"} and follow nextStep; never bypass the harness.`);
|
|
85
|
+
};
|
|
86
|
+
const readJson = (file) => JSON.parse(fs.readFileSync(file, 'utf8').replace(/^\uFEFF/, ''));
|
|
87
|
+
const DOCS = ['README.md', 'Documents/REQUIREMENTS.md', 'Documents/WORKFLOW.md', 'Documents/REVIEW.md', 'Documents/ASSETS.md', 'Documents/DECISIONS.md'];
|
|
88
|
+
|
|
89
|
+
function modelFields(value, label) {
|
|
90
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) deny(`${label} must be an object`);
|
|
91
|
+
const fields = ['provider', 'model', 'variant'];
|
|
92
|
+
if (fields.some((key) => typeof value[key] !== 'string' || !value[key].trim())) deny(`${label} requires provider, model and reasoning variant`);
|
|
93
|
+
return Object.fromEntries(fields.map((key) => [key, value[key].trim()]));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function modelChoice(value, label, { requireBackup = false } = {}) {
|
|
97
|
+
const choice = modelFields(value, label);
|
|
98
|
+
if (value.backup !== undefined) {
|
|
99
|
+
choice.backup = modelFields(value.backup, `${label}.backup`);
|
|
100
|
+
} else {
|
|
101
|
+
if (requireBackup) deny(`${label} requires a backup provider, model and reasoning variant`);
|
|
102
|
+
choice.backup = null;
|
|
103
|
+
}
|
|
104
|
+
return choice;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function modelRole(choice, runtime) {
|
|
108
|
+
if (!choice || !runtime) return null;
|
|
109
|
+
const matches = (entry) => entry && entry.provider === runtime.providerID && entry.model === runtime.modelID && entry.variant === runtime.variant;
|
|
110
|
+
if (matches(choice)) return 'primary';
|
|
111
|
+
if (matches(choice.backup)) return 'backup';
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function modelSummary(choice) {
|
|
116
|
+
if (!choice) return null;
|
|
117
|
+
return {
|
|
118
|
+
provider: choice.provider,
|
|
119
|
+
model: choice.model,
|
|
120
|
+
variant: choice.variant,
|
|
121
|
+
backup: choice.backup ? { provider: choice.backup.provider, model: choice.backup.model, variant: choice.backup.variant } : null,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function contained(root, relative, missing = false) {
|
|
126
|
+
if (typeof relative !== 'string' || !relative || path.isAbsolute(relative) || relative.includes(':') || relative.includes('\0')) deny('relative path required');
|
|
127
|
+
const full = path.resolve(root, relative);
|
|
128
|
+
if (!full.startsWith(path.resolve(root) + path.sep)) deny('path escapes boundary');
|
|
129
|
+
let current = root;
|
|
130
|
+
for (const part of path.relative(root, full).split(path.sep)) {
|
|
131
|
+
current = path.join(current, part);
|
|
132
|
+
if (!fs.existsSync(current)) { if (missing) continue; deny(`missing path: ${relative}`); }
|
|
133
|
+
if (fs.lstatSync(current).isSymbolicLink()) deny(`linked path rejected: ${relative}`);
|
|
134
|
+
}
|
|
135
|
+
return full;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function documentPath(root, requested) {
|
|
139
|
+
if (typeof requested !== 'string' || !requested.trim()) deny('document path required');
|
|
140
|
+
const absolute = path.isAbsolute(requested) ? path.resolve(requested) : path.resolve(root, requested);
|
|
141
|
+
const clean = path.relative(root, absolute).replaceAll('\\', '/');
|
|
142
|
+
if (!clean || clean.startsWith('..') || path.isAbsolute(clean)) deny('document path outside project');
|
|
143
|
+
return { full: contained(root, clean, true), clean };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function git(root, args, { binary = false, allowExitOne = false } = {}) {
|
|
147
|
+
const result = spawnSync('git', args, { cwd: root, encoding: binary ? undefined : 'utf8', windowsHide: true, timeout: 30000, maxBuffer: 128 * 1024 * 1024 });
|
|
148
|
+
if (result.error || (result.status !== 0 && !(allowExitOne && result.status === 1))) deny(`git ${args[0]} failed: ${result.error?.message ?? result.stderr?.toString().trim()}`);
|
|
149
|
+
return binary ? result.stdout : result.stdout;
|
|
150
|
+
}
|
|
151
|
+
const gitText = (root, args) => git(root, args).trim();
|
|
152
|
+
function committableFiles(root, files) {
|
|
153
|
+
if (!files.length) return [];
|
|
154
|
+
const ignored = new Set(git(root, ['check-ignore', '--', ...files], { allowExitOne: true })
|
|
155
|
+
.split(/\r?\n/).filter(Boolean));
|
|
156
|
+
return files.filter((name) => !ignored.has(name));
|
|
157
|
+
}
|
|
158
|
+
function field(text, key) {
|
|
159
|
+
const matches = [...text.matchAll(new RegExp(`^${key}:[ \\t]*([^\\r\\n]*)`, 'gm'))];
|
|
160
|
+
if (matches.length !== 1) deny(`record needs exactly one ${key}`);
|
|
161
|
+
return matches[0][1].trim();
|
|
162
|
+
}
|
|
163
|
+
function date(value) {
|
|
164
|
+
const parsed = new Date(`${value}T00:00:00Z`);
|
|
165
|
+
return /^\d{4}-\d{2}-\d{2}$/.test(value) && !Number.isNaN(parsed.valueOf()) && parsed.toISOString().slice(0, 10) === value;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export class Harness {
|
|
169
|
+
constructor(root, { stateRoot } = {}) {
|
|
170
|
+
this.root = fs.realpathSync(root);
|
|
171
|
+
this.home = stateRoot ?? path.join(os.homedir(), '.local', 'state', 'blender-workflow-harness', digest(this.root.toLowerCase()));
|
|
172
|
+
fs.mkdirSync(this.home, { recursive: true, mode: 0o700 });
|
|
173
|
+
if (fs.lstatSync(this.home).isSymbolicLink()) deny('linked state directory');
|
|
174
|
+
this.file = path.join(this.home, 'state.json');
|
|
175
|
+
this.lock = path.join(this.home, 'state.lock');
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
transaction(fn) {
|
|
179
|
+
let fd;
|
|
180
|
+
try { fd = fs.openSync(this.lock, 'wx', 0o600); }
|
|
181
|
+
catch {
|
|
182
|
+
let owner;
|
|
183
|
+
try { owner = readJson(this.lock); } catch { deny('state locked; inspect owner before manual recovery'); }
|
|
184
|
+
if (!Number.isInteger(owner?.pid) || owner.pid <= 0) deny('state lock owner is invalid; inspect before manual recovery');
|
|
185
|
+
try { process.kill(owner.pid, 0); deny('state locked by a live writer; inspect owner before manual recovery'); }
|
|
186
|
+
catch (error) {
|
|
187
|
+
if (error?.message?.startsWith('Workflow gate:')) throw error;
|
|
188
|
+
if (error?.code === 'EPERM') deny('state lock owner is live; inspect owner before manual recovery');
|
|
189
|
+
try { fs.unlinkSync(this.lock); } catch { deny('state lock owner exited; manual recovery required'); }
|
|
190
|
+
try { fd = fs.openSync(this.lock, 'wx', 0o600); } catch { deny('state lock changed during recovery; retry'); }
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
try {
|
|
194
|
+
fs.writeFileSync(fd, JSON.stringify({ pid: process.pid, created: new Date().toISOString() }));
|
|
195
|
+
const state = fs.existsSync(this.file) ? readJson(this.file) : { version: 1, projects: {}, tickets: {}, challenge: null };
|
|
196
|
+
if (state.version !== 1) deny('unsupported state version');
|
|
197
|
+
const result = fn(state);
|
|
198
|
+
const temporary = `${this.file}.${crypto.randomUUID()}.tmp`;
|
|
199
|
+
fs.writeFileSync(temporary, JSON.stringify(state, null, 2), { mode: 0o600 });
|
|
200
|
+
fs.renameSync(temporary, this.file);
|
|
201
|
+
return result;
|
|
202
|
+
} finally { fs.closeSync(fd); fs.unlinkSync(this.lock); }
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
projectPath(project) {
|
|
206
|
+
if (!/^Blender\/[A-Za-z0-9][A-Za-z0-9 _-]*$/.test(project ?? '')) deny('project must be Blender/<Name>');
|
|
207
|
+
return contained(this.root, project);
|
|
208
|
+
}
|
|
209
|
+
hash(name) { return digest(fs.readFileSync(contained(this.root, name))); }
|
|
210
|
+
taskDocument(p) {
|
|
211
|
+
return `${p.project}/${TASKSTRACKER_DOCUMENT}`;
|
|
212
|
+
}
|
|
213
|
+
head() { return gitText(this.root, ['rev-parse', 'HEAD']); }
|
|
214
|
+
snapshot(project) {
|
|
215
|
+
this.projectPath(project);
|
|
216
|
+
const files = git(this.root, ['ls-files', '--others', '--cached', '--exclude-standard', '-z', '--', project]).split('\0').filter(Boolean);
|
|
217
|
+
const result = {};
|
|
218
|
+
for (const name of files) {
|
|
219
|
+
const full = contained(this.root, name, true);
|
|
220
|
+
if (fs.existsSync(full)) result[name] = gitText(this.root, ['hash-object', `--path=${name}`, full]);
|
|
221
|
+
}
|
|
222
|
+
return result;
|
|
223
|
+
}
|
|
224
|
+
assertCommitted(project, snapshot) {
|
|
225
|
+
const names = git(this.root, ['ls-tree', '-r', '--name-only', '-z', 'HEAD', '--', project]).split('\0').filter(Boolean);
|
|
226
|
+
const committed = Object.fromEntries(names.map((name) => [name, gitText(this.root, ['rev-parse', `HEAD:${name}`])]));
|
|
227
|
+
if (!names.length || stable(committed) !== stable(snapshot) || gitText(this.root, ['status', '--porcelain', '--untracked-files=all', '--', project])) deny('commit missing or stale; commit exact scaffold/checkpoint first');
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
records(project, goal) {
|
|
231
|
+
const base = this.projectPath(project);
|
|
232
|
+
const docs = Object.fromEntries(DOCS.map((name) => [name, fs.readFileSync(contained(base, name), 'utf8')]));
|
|
233
|
+
for (const [name, text] of Object.entries(docs)) {
|
|
234
|
+
try { validateDocument(name, text); } catch (error) { deny(error.message); }
|
|
235
|
+
}
|
|
236
|
+
const req = docs['Documents/REQUIREMENTS.md'];
|
|
237
|
+
const confirmation = field(req, 'Confirmation');
|
|
238
|
+
if (field(req, 'Status') !== 'Confirmed' || !date(field(req, 'Confirmed')) || !/[\p{L}\p{N}]/u.test(confirmation) || /^(TBD|Pending|Unset|<.*>)$/i.test(confirmation)) deny('requirements not confirmed');
|
|
239
|
+
const workflow = field(docs['Documents/WORKFLOW.md'], 'Mode');
|
|
240
|
+
const review = field(docs['Documents/REVIEW.md'], 'Mode');
|
|
241
|
+
if (!['Fully Autonomous', 'Partially Autonomous'].includes(workflow) || !date(field(docs['Documents/WORKFLOW.md'], 'Selected'))) deny('workflow not selected');
|
|
242
|
+
if (!['Off', 'All', 'Single'].includes(review) || !date(field(docs['Documents/REVIEW.md'], 'Selected'))) deny('review not selected');
|
|
243
|
+
if (!/^Documents\/Goals\/\d+-[a-z0-9-]+\.md$/.test(goal ?? '')) deny('invalid goal path');
|
|
244
|
+
const goalText = fs.readFileSync(contained(base, goal), 'utf8');
|
|
245
|
+
try { validateDocument(goal, goalText); } catch (error) { deny(error.message); }
|
|
246
|
+
if (goalText.includes('{{') || goalText.trim().length < 100) deny('goal prompt not filled');
|
|
247
|
+
let roster = [], maxParallel = 1, maxRounds = 2;
|
|
248
|
+
if (review !== 'Off') {
|
|
249
|
+
const config = readJson(configPath(this.root));
|
|
250
|
+
const cfg = config.blender_subagents?.graders;
|
|
251
|
+
if (config.blender_graders || config.workers || !cfg || cfg.enabled !== true || cfg.agent !== 'blender-grader' || !Array.isArray(cfg.graders) || !cfg.graders.length) deny('grader config missing, legacy, or disabled');
|
|
252
|
+
const names = new Set();
|
|
253
|
+
const graderSteps = this.agentSteps('blender-grader');
|
|
254
|
+
for (const g of cfg.graders) {
|
|
255
|
+
if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(g.name ?? '') || names.has(g.name) || typeof g.focus !== 'string' || !g.focus.trim() || !Number.isInteger(g.max_steps) || g.max_steps < 1 || g.max_steps > 12 || !Number.isInteger(g.max_minutes) || g.max_minutes < 1) deny('invalid grader roster');
|
|
256
|
+
const choice = modelChoice(g, `grader ${g.name ?? '<unknown>'}`, { requireBackup: true });
|
|
257
|
+
g.provider = choice.provider; g.model = choice.model; g.variant = choice.variant;
|
|
258
|
+
if (choice.backup) g.backup = choice.backup; else delete g.backup;
|
|
259
|
+
if (graderSteps && g.max_steps > graderSteps) deny('invalid grader roster');
|
|
260
|
+
names.add(g.name);
|
|
261
|
+
}
|
|
262
|
+
if (!Number.isInteger(cfg.max_parallel) || cfg.max_parallel < 1 || cfg.max_parallel > names.size || !Number.isInteger(cfg.max_rounds) || cfg.max_rounds < 1 || cfg.max_rounds > 4) deny('invalid grader limits');
|
|
263
|
+
roster = cfg.graders; maxParallel = cfg.max_parallel; maxRounds = cfg.max_rounds;
|
|
264
|
+
if (review === 'Single') {
|
|
265
|
+
roster = roster.filter((g) => g.name === field(docs['Documents/REVIEW.md'], 'Single grader'));
|
|
266
|
+
if (roster.length !== 1) deny('unknown Single grader');
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
const specs = Object.fromEntries(['Documents/REQUIREMENTS.md', 'Documents/WORKFLOW.md', 'Documents/REVIEW.md', goal].map((name) => [`${project}/${name}`, this.hash(`${project}/${name}`)]));
|
|
270
|
+
return { workflow, review, roster, maxParallel, maxRounds, specs, goalText };
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
agentSteps(name) {
|
|
274
|
+
const file = contained(this.root, `.kilo/agent/${name}.md`, true);
|
|
275
|
+
if (!fs.existsSync(file)) return null;
|
|
276
|
+
const steps = Number(/^steps:\s*(\d+)\s*$/m.exec(fs.readFileSync(file, 'utf8'))?.[1]);
|
|
277
|
+
return Number.isInteger(steps) ? steps : null;
|
|
278
|
+
}
|
|
279
|
+
developmentMode(session) {
|
|
280
|
+
const file = configPath(this.root);
|
|
281
|
+
if (!fs.existsSync(file)) return false;
|
|
282
|
+
try {
|
|
283
|
+
const config = readJson(file);
|
|
284
|
+
if (config.development_mode === true) return true;
|
|
285
|
+
return typeof session === 'string'
|
|
286
|
+
&& Array.isArray(config.development_sessions)
|
|
287
|
+
&& config.development_sessions.includes(session);
|
|
288
|
+
} catch { return false; }
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
workerConfig() {
|
|
292
|
+
const file = configPath(this.root);
|
|
293
|
+
if (!fs.existsSync(file)) deny('worker config missing; copy config.example.json and confirm it before delegation');
|
|
294
|
+
const cfg = readJson(file).blender_subagents?.workers;
|
|
295
|
+
if (!cfg || typeof cfg !== 'object' || Array.isArray(cfg) || cfg.agent !== 'blender-worker') {
|
|
296
|
+
deny('invalid worker config; validate blender_subagents.workers before delegation');
|
|
297
|
+
}
|
|
298
|
+
const choice = modelChoice(cfg, 'worker', { requireBackup: true });
|
|
299
|
+
if (!Number.isInteger(cfg.max_minutes) || cfg.max_minutes < 1 || !Number.isInteger(cfg.max_steps) || cfg.max_steps < 1
|
|
300
|
+
|| !Number.isInteger(cfg.max_failed_attempts) || ![1, 2].includes(cfg.max_failed_attempts) || cfg.max_parallel !== 1) {
|
|
301
|
+
deny('invalid worker limits; max_parallel must be 1');
|
|
302
|
+
}
|
|
303
|
+
const steps = this.agentSteps('blender-worker');
|
|
304
|
+
if (steps && cfg.max_steps > steps) deny('worker max_steps exceeds agent steps ceiling');
|
|
305
|
+
return { ...cfg, ...choice };
|
|
306
|
+
}
|
|
307
|
+
projectDoctor(project) {
|
|
308
|
+
return this.transaction((state) => {
|
|
309
|
+
const p = state.projects[project];
|
|
310
|
+
if (!p) deny('project is not registered');
|
|
311
|
+
const base = path.join(this.root, ...project.split('/'));
|
|
312
|
+
const tracker = path.join(base, 'Documents', 'TASKSTRACKER.md');
|
|
313
|
+
const tasks = path.join(base, 'Documents', 'Tasks');
|
|
314
|
+
return {
|
|
315
|
+
project, owner: p.orchestrator, goalActive: Boolean(p.goalActive), complete: Boolean(p.complete),
|
|
316
|
+
staleOwnerWindow: Boolean(p.change ? p.change.deadline < Date.now() : false),
|
|
317
|
+
change: p.change ? { id: p.change.id, milestone: p.change.milestone, operations: p.change.operations, deadline: p.change.deadline, verdict: p.change.verdict, blockingKind: p.change.blockingKind ?? null } : null,
|
|
318
|
+
scene: { path: p.scene, exists: fs.existsSync(path.join(this.root, p.scene)) },
|
|
319
|
+
docs: { tracker: fs.existsSync(tracker), tasks: fs.existsSync(tasks) },
|
|
320
|
+
models: {
|
|
321
|
+
worker: (() => { try { return modelSummary(this.workerConfig()); } catch (error) { return { error: String(error.message).slice(0, 300) }; } })(),
|
|
322
|
+
graders: (p.records.roster ?? []).map(({ name, ...choice }) => ({ name, ...modelSummary(choice) })),
|
|
323
|
+
},
|
|
324
|
+
blender: instanceStatus(this.home),
|
|
325
|
+
};
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
owner(state, session) {
|
|
329
|
+
const projects = Object.values(state.projects).filter((p) => p.orchestrator === session);
|
|
330
|
+
if (projects.length !== 1) deny('register exactly one project for orchestrator session');
|
|
331
|
+
return projects[0];
|
|
332
|
+
}
|
|
333
|
+
fresh(p, { allowRosterDrift = false } = {}) {
|
|
334
|
+
const now = this.records(p.project, p.goal);
|
|
335
|
+
const frozen = ['specs', 'workflow', 'review', 'goalText'];
|
|
336
|
+
if (frozen.some((key) => stable(now[key]) !== stable(p.records[key]))) deny('spec, modes, or goal changed; explicit re-registration required');
|
|
337
|
+
if (!allowRosterDrift && stable(now.roster) !== stable(p.records.roster)) deny('roster changed; explicit re-registration required');
|
|
338
|
+
const c = p.change;
|
|
339
|
+
if (c?.sealed && this.hash(p.scene) !== c.sceneHash) deny('scene changed after sealing');
|
|
340
|
+
for (const [name, hash] of Object.entries(c?.evidence ?? {})) if (this.hash(name) !== hash) deny(`stale evidence: ${name}`);
|
|
341
|
+
for (const [name, hash] of Object.entries(c?.reports ?? {})) if (this.hash(name) !== hash) deny(`report changed after completion: ${name}`);
|
|
342
|
+
if (c?.manifestHash && this.hash(c.manifestPath) !== c.manifestHash) deny('frozen review manifest changed');
|
|
343
|
+
}
|
|
344
|
+
writeTaskLedger(p) {
|
|
345
|
+
const escape = (value) => String(value ?? '').replaceAll('|', '\\|').replace(/[\r\n]+/g, ' ').slice(0, 160);
|
|
346
|
+
const folder = (task) => task.taskDoc ? `[${task.id}](Tasks/${task.id}/TASK.md)` : escape(task.id);
|
|
347
|
+
const rows = (p.tasks ?? []).map((task) => {
|
|
348
|
+
const objects = Array.isArray(task.objects) ? task.objects.length : 0;
|
|
349
|
+
return `| ${folder(task)} | ${escape(task.kind ?? 'operation')} | ${escape(task.change)} | ${escape(task.operation)} | ${objects} | ${escape(task.worker)} | ${escape(task.child ?? '')} | ${escape(task.state)} | ${escape(task.created)} | ${escape(task.finished ?? '')} | ${escape(task.result ?? '')} |`;
|
|
350
|
+
});
|
|
351
|
+
const text = [
|
|
352
|
+
`# Task Tracker — ${p.project.slice('Blender/'.length)}`,
|
|
353
|
+
'',
|
|
354
|
+
`Status: ${p.complete ? 'Complete' : 'Active'}`,
|
|
355
|
+
`Last updated: ${new Date().toISOString()}`,
|
|
356
|
+
'',
|
|
357
|
+
'## Tasks',
|
|
358
|
+
'',
|
|
359
|
+
'| Task | Kind | Change | Operation | Objects | Assigned | Child | State | Created | Finished | Result |',
|
|
360
|
+
'|---|---|---|---|---|---|---|---|---|---|---|',
|
|
361
|
+
...(rows.length ? rows : ['| — | — | — | — | — | — | — | — | — | — | — |']),
|
|
362
|
+
'',
|
|
363
|
+
].join('\n');
|
|
364
|
+
const full = contained(this.root, this.taskDocument(p), true);
|
|
365
|
+
const previous = fs.existsSync(full) ? fs.readFileSync(full, 'utf8') : null;
|
|
366
|
+
const withoutTimestamp = (value) => String(value ?? '').replace(/^Last updated:[^\r\n]*\r?\n/m, '');
|
|
367
|
+
if (previous !== null && withoutTimestamp(previous) === withoutTimestamp(text)) return;
|
|
368
|
+
fs.mkdirSync(path.dirname(full), { recursive: true });
|
|
369
|
+
fs.writeFileSync(full, text);
|
|
370
|
+
}
|
|
371
|
+
commitTaskTrackerCompletion(p) {
|
|
372
|
+
this.writeTaskLedger(p);
|
|
373
|
+
const tracker = this.taskDocument(p);
|
|
374
|
+
if (!gitText(this.root, ['status', '--porcelain', '--', tracker])) return null;
|
|
375
|
+
if (gitText(this.root, ['diff', '--cached', '--name-only'])) deny('index must be clean before completion metadata commit');
|
|
376
|
+
git(this.root, ['add', '--', tracker]);
|
|
377
|
+
if (!gitText(this.root, ['diff', '--cached', '--name-only'])) return null;
|
|
378
|
+
git(this.root, ['commit', '-m', 'chore: mark task tracker complete']);
|
|
379
|
+
p.lastCommit = this.head();
|
|
380
|
+
p.checkpoint = this.head();
|
|
381
|
+
return p.lastCommit;
|
|
382
|
+
}
|
|
383
|
+
ensureTaskLedger(p) {
|
|
384
|
+
p.tasks ??= [];
|
|
385
|
+
p.taskSeq ??= 0;
|
|
386
|
+
const full = contained(this.root, this.taskDocument(p), true);
|
|
387
|
+
if (!fs.existsSync(full)) this.writeTaskLedger(p);
|
|
388
|
+
}
|
|
389
|
+
documentLedger(session, args = {}) {
|
|
390
|
+
return this.transaction((s) => {
|
|
391
|
+
if (Object.keys(args).some((key) => key !== 'project')) deny('document ledger accepts project only');
|
|
392
|
+
const p = this.owner(s, session);
|
|
393
|
+
if (args.project && args.project !== p.project) deny('document ledger project mismatch');
|
|
394
|
+
this.ensureTaskLedger(p);
|
|
395
|
+
return {
|
|
396
|
+
project: p.project, path: this.taskDocument(p), status: p.complete ? 'Complete' : 'Active',
|
|
397
|
+
tasks: (p.tasks ?? []).map(({ token: _token, ...task }) => task),
|
|
398
|
+
};
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
taskDocPath(task) { return `Documents/Tasks/${task.id}/TASK.md`; }
|
|
402
|
+
taskSummaryPath(task) { return `Documents/Tasks/${task.id}/SUMMARY.md`; }
|
|
403
|
+
taskFiles(p, c) {
|
|
404
|
+
return (p.tasks ?? []).filter((task) => task.change === c.id).flatMap((task) => [task.taskDoc, task.summaryDoc]).filter(Boolean);
|
|
405
|
+
}
|
|
406
|
+
writeTaskDocument(p, task) {
|
|
407
|
+
const base = this.projectPath(p.project);
|
|
408
|
+
const relative = this.taskDocPath(task);
|
|
409
|
+
const full = contained(base, relative, true);
|
|
410
|
+
const paths = Array.isArray(task.paths) ? task.paths : [];
|
|
411
|
+
const objects = Array.isArray(task.objects) ? task.objects : [];
|
|
412
|
+
const text = [
|
|
413
|
+
`# ${task.id} — ${task.kind ?? 'operation'}`,
|
|
414
|
+
'',
|
|
415
|
+
`- Change: ${task.change}`,
|
|
416
|
+
`- Operation: ${task.operation}`,
|
|
417
|
+
`- State: ${task.state}`,
|
|
418
|
+
`- Actor: ${task.actor ?? task.worker}`,
|
|
419
|
+
`- Capability: ${task.capability ?? task.operation}`,
|
|
420
|
+
`- Assigned: ${task.worker}`,
|
|
421
|
+
`- Child session: ${task.child ?? ''}`,
|
|
422
|
+
`- Created: ${task.created}`,
|
|
423
|
+
`- Finished: ${task.finished ?? ''}`,
|
|
424
|
+
`- Deadline: ${task.deadline ? new Date(task.deadline).toISOString() : ''}`,
|
|
425
|
+
`- Token SHA-256: ${task.tokenHash}`,
|
|
426
|
+
'',
|
|
427
|
+
'## Objects',
|
|
428
|
+
'',
|
|
429
|
+
...(objects.length ? objects.map((name) => `- ${name}`) : ['- —']),
|
|
430
|
+
'',
|
|
431
|
+
'## Paths',
|
|
432
|
+
'',
|
|
433
|
+
...(paths.length ? paths.map((name) => `- ${name}`) : ['- —']),
|
|
434
|
+
'',
|
|
435
|
+
'## Operation Results',
|
|
436
|
+
'',
|
|
437
|
+
...((task.operationResults ?? []).length
|
|
438
|
+
? task.operationResults.map((item) => `- ${item.index ?? '—'}: ${item.operation ?? '—'} ${item.ok ? 'ok' : `failed: ${item.error ?? 'unknown error'}`}`)
|
|
439
|
+
: ['- —']),
|
|
440
|
+
'',
|
|
441
|
+
].join('\n');
|
|
442
|
+
fs.mkdirSync(path.dirname(full), { recursive: true });
|
|
443
|
+
fs.writeFileSync(full, text);
|
|
444
|
+
task.taskDoc = `${p.project}/${relative}`;
|
|
445
|
+
}
|
|
446
|
+
writeTaskSummary(p, task) {
|
|
447
|
+
const base = this.projectPath(p.project);
|
|
448
|
+
const relative = this.taskSummaryPath(task);
|
|
449
|
+
const full = contained(base, relative, true);
|
|
450
|
+
const text = [
|
|
451
|
+
`# ${task.id} — Summary`,
|
|
452
|
+
'',
|
|
453
|
+
`- Change: ${task.change}`,
|
|
454
|
+
`- Operation: ${task.operation}`,
|
|
455
|
+
`- State: ${task.state}`,
|
|
456
|
+
`- Actor: ${task.actor ?? task.worker}`,
|
|
457
|
+
`- Capability: ${task.capability ?? task.operation}`,
|
|
458
|
+
`- Child session: ${task.child ?? ''}`,
|
|
459
|
+
`- Created: ${task.created}`,
|
|
460
|
+
`- Finished: ${task.finished ?? ''}`,
|
|
461
|
+
`- Result: ${task.result ?? ''}`,
|
|
462
|
+
'',
|
|
463
|
+
'## Operation Results',
|
|
464
|
+
'',
|
|
465
|
+
...((task.operationResults ?? []).length
|
|
466
|
+
? task.operationResults.map((item) => `- ${item.index ?? '—'}: ${item.operation ?? '—'} ${item.ok ? 'ok' : `failed: ${item.error ?? 'unknown error'}`}`)
|
|
467
|
+
: ['- —']),
|
|
468
|
+
'',
|
|
469
|
+
].join('\n');
|
|
470
|
+
fs.mkdirSync(path.dirname(full), { recursive: true });
|
|
471
|
+
fs.writeFileSync(full, text);
|
|
472
|
+
task.summaryDoc = `${p.project}/${relative}`;
|
|
473
|
+
}
|
|
474
|
+
recordTaskIssued(p, token, operation, payload, deadline = p.change.deadline ?? null) {
|
|
475
|
+
this.ensureTaskLedger(p);
|
|
476
|
+
const operations = operation === 'apply' && Array.isArray(payload?.operations) ? payload.operations : [payload];
|
|
477
|
+
const taskObjects = (item) => {
|
|
478
|
+
if (!item || typeof item !== 'object') return [];
|
|
479
|
+
if (item.operation === 'shared_material') return Array.isArray(item.objects) ? item.objects : [];
|
|
480
|
+
return [item.name, item.new_name, item.dof?.focus_object];
|
|
481
|
+
};
|
|
482
|
+
const objects = [...new Set(operations.flatMap(taskObjects).filter((name) => typeof name === 'string'))];
|
|
483
|
+
const task = {
|
|
484
|
+
id: `T${String(++p.taskSeq).padStart(3, '0')}`, kind: 'operation', change: p.change.id,
|
|
485
|
+
operation: operation === 'apply' && operations.length > 1 ? `apply x${operations.length}` : operation,
|
|
486
|
+
objects, paths: p.change.paths ? [...p.change.paths] : [], token, tokenHash: digest(token).slice(0, 12),
|
|
487
|
+
worker: operation === 'apply' ? 'blender-worker' : 'orchestrator',
|
|
488
|
+
actor: operation === 'apply' ? 'blender-worker' : 'orchestrator', capability: operation, child: '',
|
|
489
|
+
state: 'issued', created: new Date().toISOString(), finished: '', result: '',
|
|
490
|
+
deadline, operationResults: [],
|
|
491
|
+
};
|
|
492
|
+
p.tasks.push(task);
|
|
493
|
+
this.writeTaskDocument(p, task);
|
|
494
|
+
this.writeTaskLedger(p);
|
|
495
|
+
return task;
|
|
496
|
+
}
|
|
497
|
+
recordGraderTask(p, token, name, report, deadline = p.change.deadline ?? null) {
|
|
498
|
+
this.ensureTaskLedger(p);
|
|
499
|
+
const task = {
|
|
500
|
+
id: `G${String(++p.taskSeq).padStart(3, '0')}`, kind: 'grader', change: p.change.id,
|
|
501
|
+
operation: `grader:${name}`, objects: [], paths: [report], token, tokenHash: digest(token).slice(0, 12),
|
|
502
|
+
worker: name, actor: 'blender-grader', capability: 'grade', child: '', state: 'issued', created: new Date().toISOString(), finished: '', result: '',
|
|
503
|
+
deadline, operationResults: [],
|
|
504
|
+
};
|
|
505
|
+
p.tasks.push(task);
|
|
506
|
+
this.writeTaskDocument(p, task);
|
|
507
|
+
this.writeTaskLedger(p);
|
|
508
|
+
return task;
|
|
509
|
+
}
|
|
510
|
+
recordTaskFinished(p, token, result) {
|
|
511
|
+
const task = p.tasks?.find((item) => item.token === token);
|
|
512
|
+
if (!task) return;
|
|
513
|
+
task.state = result.ok ? 'done' : 'failed';
|
|
514
|
+
task.finished = new Date().toISOString();
|
|
515
|
+
task.operationResults = Array.isArray(result.operationResults) ? result.operationResults : [];
|
|
516
|
+
task.result = result.ok ? (result.completed ? `ok (${result.completed} ops)` : 'ok') : `${result.completed ? `failed after ${result.completed} ops: ` : ''}${String(result.error ?? 'failed')}`.slice(0, 300);
|
|
517
|
+
this.writeTaskDocument(p, task);
|
|
518
|
+
this.writeTaskSummary(p, task);
|
|
519
|
+
this.writeTaskLedger(p);
|
|
520
|
+
}
|
|
521
|
+
updateManifest(p, extra = {}) {
|
|
522
|
+
const c = p.change;
|
|
523
|
+
if (!c?.manifestPath) return;
|
|
524
|
+
const full = contained(this.root, c.manifestPath);
|
|
525
|
+
const manifest = readJson(full);
|
|
526
|
+
Object.assign(manifest, extra, { tasks: c.graders, operation_tasks: (p.tasks ?? []).filter((task) => task.change === c.id).map(({ token: _token, ...task }) => task), verdict: c.verdict ?? null });
|
|
527
|
+
const text = JSON.stringify(manifest, null, 2) + '\n';
|
|
528
|
+
fs.writeFileSync(full, text);
|
|
529
|
+
c.manifestHash = digest(text);
|
|
530
|
+
}
|
|
531
|
+
bootstrapDocument(session, args) {
|
|
532
|
+
return this.transaction((s) => {
|
|
533
|
+
if (Object.keys(args).some((key) => !['project', 'path', 'content'].includes(key))) deny('bootstrap-document accepts project, path and content only');
|
|
534
|
+
const base = this.projectPath(args.project);
|
|
535
|
+
const existing = s.projects[args.project];
|
|
536
|
+
if (existing && (existing.orchestrator !== session || !existing.reregistering || existing.change)) deny('project already registered; use reregister before editing registration documents');
|
|
537
|
+
if (s.challenge?.project === args.project && s.challenge.session !== session) deny('another session owns the pending registration');
|
|
538
|
+
const full = contained(base, args.path, true);
|
|
539
|
+
const clean = path.relative(base, full).replaceAll('\\', '/');
|
|
540
|
+
if (!isBootstrapDocument(clean)) deny('bootstrap-document writes project markdown outside review artifacts only');
|
|
541
|
+
try { validateDocument(clean, args.content); } catch (error) { deny(error.message); }
|
|
542
|
+
fs.mkdirSync(path.dirname(full), { recursive: true });
|
|
543
|
+
fs.writeFileSync(full, args.content);
|
|
544
|
+
if (s.challenge?.project === args.project) s.challenge = null;
|
|
545
|
+
return { project: args.project, path: clean, sha256: digest(args.content), next: 'Commit the exact scaffold with bootstrap-commit, then call prepare.' };
|
|
546
|
+
});
|
|
547
|
+
}
|
|
548
|
+
intake(session, args) {
|
|
549
|
+
return this.transaction((s) => {
|
|
550
|
+
const allowed = ['project', 'files', 'folders', 'kind', 'source', 'generated', 'scan'];
|
|
551
|
+
if (Object.keys(args).some((key) => !allowed.includes(key))) deny('intake accepts project, files, folders, kind, source, generated and scan only');
|
|
552
|
+
if (typeof args.project !== 'string' || !/^Blender\/[A-Za-z0-9][A-Za-z0-9 _-]*$/.test(args.project)) deny('intake project must be Blender/<Name>');
|
|
553
|
+
if (args.scan !== undefined && typeof args.scan !== 'boolean') deny('intake scan must be a boolean');
|
|
554
|
+
if (args.files !== undefined && (!Array.isArray(args.files) || args.files.length > 100 || args.files.some((item) => typeof item !== 'string'))) {
|
|
555
|
+
deny('intake files must be an array of paths');
|
|
556
|
+
}
|
|
557
|
+
if (args.folders !== undefined && (!Array.isArray(args.folders) || args.folders.length > 20 || args.folders.some((item) => typeof item !== 'string'))) {
|
|
558
|
+
deny('intake folders must be an array of paths');
|
|
559
|
+
}
|
|
560
|
+
const projectName = path.basename(args.project);
|
|
561
|
+
const p = Object.values(s.projects).find((item) => item.project === args.project);
|
|
562
|
+
if (p && p.orchestrator !== session) deny('intake requires the owning session');
|
|
563
|
+
const c = p?.change;
|
|
564
|
+
if (args.scan !== true && !p) deny('intake copy requires a registered project; scan first, then register and begin a change');
|
|
565
|
+
if (args.scan !== true && !c) deny('intake copy requires an open bounded change; call begin with the intake document path first');
|
|
566
|
+
if (args.scan !== true && (c.bootstrap || c.sealed || c.pending || c.readyToSeal)) deny('intake copy requires an unsealed, unfinished, non-bootstrap scene change with no pending operation');
|
|
567
|
+
const plan = planIntake({
|
|
568
|
+
root: this.root,
|
|
569
|
+
projectName,
|
|
570
|
+
files: args.files ?? [],
|
|
571
|
+
folders: args.folders ?? [],
|
|
572
|
+
kind: args.kind ?? 'auto',
|
|
573
|
+
source: args.source,
|
|
574
|
+
generated: args.generated,
|
|
575
|
+
home: this.home,
|
|
576
|
+
});
|
|
577
|
+
if (args.scan === true || !c) {
|
|
578
|
+
return {
|
|
579
|
+
project: args.project,
|
|
580
|
+
scan: true,
|
|
581
|
+
entries: plan.entries.map(({ sourcePath, ...entry }) => ({ source: sourcePath, ...entry })),
|
|
582
|
+
rejected: plan.rejected,
|
|
583
|
+
next: c
|
|
584
|
+
? 'Review the plan, then call intake with scan:false to copy accepted files into the open change.'
|
|
585
|
+
: p
|
|
586
|
+
? 'Open a bounded change with begin, then call intake with scan:false.'
|
|
587
|
+
: 'Register the project and call begin with the intake document path before copying files.',
|
|
588
|
+
};
|
|
589
|
+
}
|
|
590
|
+
const base = this.projectPath(args.project);
|
|
591
|
+
const result = copyIntake({ projectRoot: base, plan, licenseText: plan.licenseText });
|
|
592
|
+
p.intake = {
|
|
593
|
+
at: new Date().toISOString(),
|
|
594
|
+
copied: result.copied.length,
|
|
595
|
+
duplicates: result.duplicates.length,
|
|
596
|
+
rejected: result.rejected.length,
|
|
597
|
+
licenseCandidates: [...new Set(result.copied.flatMap((entry) => entry.licenseCandidates ?? []))],
|
|
598
|
+
paths: result.copied.map((entry) => entry.stored),
|
|
599
|
+
};
|
|
600
|
+
const paths = new Set(c.paths);
|
|
601
|
+
const addPath = (relative) => paths.add(`${p.project}/${relative}`);
|
|
602
|
+
const addDocument = (relative, content, validate = false) => {
|
|
603
|
+
if (validate) {
|
|
604
|
+
try { validateDocument(relative, content); } catch (error) { deny(error.message); }
|
|
605
|
+
}
|
|
606
|
+
const full = path.join(base, ...relative.split('/'));
|
|
607
|
+
fs.mkdirSync(path.dirname(full), { recursive: true });
|
|
608
|
+
fs.writeFileSync(full, content, 'utf8');
|
|
609
|
+
addPath(relative);
|
|
610
|
+
return content;
|
|
611
|
+
};
|
|
612
|
+
const readOrCreate = (relative, create) => {
|
|
613
|
+
const full = path.join(base, ...relative.split('/'));
|
|
614
|
+
return fs.existsSync(full) ? fs.readFileSync(full, 'utf8') : create();
|
|
615
|
+
};
|
|
616
|
+
const assetsRelative = 'Documents/ASSETS.md';
|
|
617
|
+
const referencesRelative = 'Documents/References/README.md';
|
|
618
|
+
const intakeRelative = 'Documents/INTAKE.md';
|
|
619
|
+
let assets = null;
|
|
620
|
+
let references = null;
|
|
621
|
+
let intake = readOrCreate(intakeRelative, () => createIntakeDocument(projectName));
|
|
622
|
+
for (const entry of result.copied) {
|
|
623
|
+
addPath(entry.stored);
|
|
624
|
+
const sourceName = plan.source?.url || plan.source?.author || plan.source?.vendor || 'user-supplied';
|
|
625
|
+
if (entry.external) {
|
|
626
|
+
const sourceRelative = path.posix.join(path.posix.dirname(entry.stored), 'SOURCE.md');
|
|
627
|
+
const licenseRelative = path.posix.join(path.posix.dirname(entry.stored), 'LICENSE.txt');
|
|
628
|
+
addDocument(sourceRelative, sourceDocument({
|
|
629
|
+
entry,
|
|
630
|
+
source: plan.source,
|
|
631
|
+
license: plan.source?.license ?? '',
|
|
632
|
+
generated: plan.generated,
|
|
633
|
+
}));
|
|
634
|
+
addPath(licenseRelative);
|
|
635
|
+
assets ??= readOrCreate(assetsRelative, () => `# External Assets — ${projectName}\n\n## Rules\n\nExternal assets are registered by the harness.\n`);
|
|
636
|
+
assets = appendAssetRow(assets, {
|
|
637
|
+
assetId: entry.sha256.slice(0, 12),
|
|
638
|
+
asset: plan.source?.name || plan.source?.asset || entry.name,
|
|
639
|
+
type: entry.kind,
|
|
640
|
+
sourceUrl: plan.source?.url || '',
|
|
641
|
+
author: plan.source?.author || plan.source?.vendor || '',
|
|
642
|
+
license: plan.source?.license || '',
|
|
643
|
+
localPath: entry.stored,
|
|
644
|
+
packaging: plan.source?.packaging || 'none',
|
|
645
|
+
redistributable: plan.source?.redistributable === undefined ? '' : plan.source.redistributable ? 'yes' : 'no',
|
|
646
|
+
sha256: entry.sha256,
|
|
647
|
+
preview: '',
|
|
648
|
+
usedIn: '',
|
|
649
|
+
notes: plan.source?.notes || '',
|
|
650
|
+
});
|
|
651
|
+
}
|
|
652
|
+
if (entry.kind === 'reference') {
|
|
653
|
+
const sidecar = entry.stored.replace(/\.[^/.]+$/, '') + '.md';
|
|
654
|
+
addDocument(sidecar, referenceSourceDocument({ entry, source: plan.source, generated: plan.generated }));
|
|
655
|
+
references ??= readOrCreate(referencesRelative, () => `# References — ${projectName}\n\n## Index\n\n| Reference | Source | What it informs | Notes |\n|---|---|---|---|\n`);
|
|
656
|
+
references = appendReferenceRow(references, {
|
|
657
|
+
reference: entry.stored,
|
|
658
|
+
source: plan.generated ? `generated: ${plan.generated.tool || 'image tool'}` : sourceName,
|
|
659
|
+
informs: plan.source?.notes || 'visual direction',
|
|
660
|
+
notes: plan.generated ? `model ${plan.generated.model || '—'}; prompt ${plan.generated.prompt || '—'}` : 'user-supplied reference',
|
|
661
|
+
});
|
|
662
|
+
}
|
|
663
|
+
intake = appendIntakeRow(intake, {
|
|
664
|
+
time: new Date().toISOString(),
|
|
665
|
+
kind: entry.kind,
|
|
666
|
+
original: entry.name,
|
|
667
|
+
stored: entry.stored,
|
|
668
|
+
bytes: entry.bytes,
|
|
669
|
+
mime: entry.mime,
|
|
670
|
+
width: entry.width,
|
|
671
|
+
height: entry.height,
|
|
672
|
+
sha256: entry.sha256,
|
|
673
|
+
source: plan.generated ? `generated: ${plan.generated.tool || 'image tool'}` : sourceName,
|
|
674
|
+
license: plan.source?.license || (plan.generated ? 'generated' : ''),
|
|
675
|
+
status: 'copied',
|
|
676
|
+
});
|
|
677
|
+
}
|
|
678
|
+
for (const duplicate of result.duplicates) {
|
|
679
|
+
intake = appendIntakeRow(intake, {
|
|
680
|
+
time: new Date().toISOString(),
|
|
681
|
+
kind: duplicate.kind,
|
|
682
|
+
original: duplicate.name,
|
|
683
|
+
stored: duplicate.stored,
|
|
684
|
+
bytes: duplicate.bytes,
|
|
685
|
+
mime: duplicate.mime,
|
|
686
|
+
width: duplicate.width,
|
|
687
|
+
height: duplicate.height,
|
|
688
|
+
sha256: duplicate.sha256,
|
|
689
|
+
source: plan.generated ? `generated: ${plan.generated.tool || 'image tool'}` : (plan.source?.url || plan.source?.author || 'user-supplied'),
|
|
690
|
+
license: plan.source?.license || (plan.generated ? 'generated' : ''),
|
|
691
|
+
status: 'duplicate',
|
|
692
|
+
});
|
|
693
|
+
}
|
|
694
|
+
if (assets !== null) {
|
|
695
|
+
addDocument(assetsRelative, assets, true);
|
|
696
|
+
if (!c.paths.includes(`${p.project}/${assetsRelative}`)) c.paths.push(`${p.project}/${assetsRelative}`);
|
|
697
|
+
}
|
|
698
|
+
if (references !== null) {
|
|
699
|
+
addDocument(referencesRelative, references, true);
|
|
700
|
+
if (!c.paths.includes(`${p.project}/${referencesRelative}`)) c.paths.push(`${p.project}/${referencesRelative}`);
|
|
701
|
+
}
|
|
702
|
+
addDocument(intakeRelative, intake, true);
|
|
703
|
+
for (const relative of paths) if (!c.paths.includes(relative)) c.paths.push(relative);
|
|
704
|
+
c.documentOnly = c.paths.every(documentOnlyPath);
|
|
705
|
+
c.nonSceneOnly = c.paths.every(nonScenePath);
|
|
706
|
+
return {
|
|
707
|
+
project: args.project,
|
|
708
|
+
scan: false,
|
|
709
|
+
copied: result.copied.map(({ sourcePath, target, ...entry }) => entry),
|
|
710
|
+
duplicates: result.duplicates,
|
|
711
|
+
rejected: result.rejected,
|
|
712
|
+
paths: [...paths].filter((name) => name.startsWith(`${p.project}/`)).map((name) => name.slice(p.project.length + 1)),
|
|
713
|
+
next: 'Import copied assets with apply operations, record their use, then continue the bounded change.',
|
|
714
|
+
};
|
|
715
|
+
});
|
|
716
|
+
}
|
|
717
|
+
async download(session, args) {
|
|
718
|
+
const allowed = ['project', 'url', 'filename', 'maxBytes'];
|
|
719
|
+
if (Object.keys(args).some((key) => !allowed.includes(key))) deny('download accepts project, url, filename and maxBytes only');
|
|
720
|
+
if (args.project !== undefined && (typeof args.project !== 'string' || !/^Blender\/[A-Za-z0-9][A-Za-z0-9 _-]*$/.test(args.project))) deny('download project must be Blender/<Name>');
|
|
721
|
+
if (typeof args.url !== 'string' || !args.url.trim()) deny('download requires url');
|
|
722
|
+
if (args.filename !== undefined && typeof args.filename !== 'string') deny('download filename must be a string');
|
|
723
|
+
if (args.maxBytes !== undefined && (!Number.isInteger(args.maxBytes) || args.maxBytes < 1)) deny('download maxBytes must be a positive integer');
|
|
724
|
+
if (args.project) this.transaction((s) => {
|
|
725
|
+
const p = Object.values(s.projects).find((item) => item.project === args.project);
|
|
726
|
+
if (p && (p.downloads ?? []).length >= MAX_DOWNLOADS_PER_PROJECT) deny(`downloads are capped at ${MAX_DOWNLOADS_PER_PROJECT} per project`);
|
|
727
|
+
});
|
|
728
|
+
const result = await downloadToStaging({ root: this.root, project: args.project, url: args.url, filename: args.filename, maxBytes: args.maxBytes });
|
|
729
|
+
if (args.project) this.transaction((s) => {
|
|
730
|
+
const p = Object.values(s.projects).find((item) => item.project === args.project);
|
|
731
|
+
if (p) p.downloads = [...(p.downloads ?? []), { at: new Date().toISOString(), url: result.url, path: result.relative, bytes: result.bytes, sha256: result.sha256 }];
|
|
732
|
+
});
|
|
733
|
+
return result;
|
|
734
|
+
}
|
|
735
|
+
bootstrapCommit(session, args) {
|
|
736
|
+
return this.transaction((s) => {
|
|
737
|
+
if (Object.keys(args).some((key) => !['project', 'message'].includes(key))) deny('bootstrap-commit accepts project and message only');
|
|
738
|
+
this.projectPath(args.project);
|
|
739
|
+
const existing = s.projects[args.project];
|
|
740
|
+
if (existing && (existing.orchestrator !== session || !existing.reregistering || existing.change)) deny('project already registered; use the harness commit action inside a bounded change');
|
|
741
|
+
if (s.challenge?.project === args.project && s.challenge.session !== session) deny('another session owns the pending registration');
|
|
742
|
+
if (typeof args.message !== 'string' || !args.message.trim() || args.message.length > 200 || /[\r\n]/.test(args.message)) deny('one bounded commit message required');
|
|
743
|
+
if (gitText(this.root, ['diff', '--cached', '--name-only'])) deny('index must be clean before bootstrap-commit');
|
|
744
|
+
const snapshot = this.snapshot(args.project);
|
|
745
|
+
if (existing?.reregistering && existing.scene && snapshot[existing.scene] !== existing.snapshot?.[existing.scene]) {
|
|
746
|
+
deny('scene cannot change during re-registration; restore the checkpointed scene');
|
|
747
|
+
}
|
|
748
|
+
const entries = Object.keys(snapshot);
|
|
749
|
+
const files = existing?.reregistering
|
|
750
|
+
? entries.filter((name) => name === existing.scene || isBootstrapFile(name.slice(args.project.length + 1)))
|
|
751
|
+
: entries;
|
|
752
|
+
if (!files.length) deny('no scaffold files found');
|
|
753
|
+
const blocked = files.filter((name) => name !== existing?.scene && !isBootstrapFile(name.slice(args.project.length + 1)));
|
|
754
|
+
if (blocked.length) deny(`bootstrap-commit only accepts scaffold documentation/metadata; remove or move: ${blocked.join(', ')}`);
|
|
755
|
+
for (const name of files.filter((name) => name.endsWith('.md'))) {
|
|
756
|
+
try { validateDocument(name.slice(args.project.length + 1), fs.readFileSync(contained(this.root, name), 'utf8')); }
|
|
757
|
+
catch (error) { deny(error.message); }
|
|
758
|
+
}
|
|
759
|
+
git(this.root, ['add', '--', ...files]);
|
|
760
|
+
if (!gitText(this.root, ['diff', '--cached', '--name-only'])) deny('no scaffold changes to commit');
|
|
761
|
+
git(this.root, ['commit', '-m', args.message.trim()]);
|
|
762
|
+
return { project: args.project, commit: this.head(), files: files.length, next: 'Call prepare and answer its exact registration question.' };
|
|
763
|
+
});
|
|
764
|
+
}
|
|
765
|
+
prepare(session, args) {
|
|
766
|
+
return this.transaction((s) => {
|
|
767
|
+
if (Object.keys(args).some((k) => !['project', 'goal', 'scene'].includes(k))) deny('prepare accepts project, goal and scene only');
|
|
768
|
+
const records = this.records(args.project, args.goal);
|
|
769
|
+
const prior = s.projects[args.project];
|
|
770
|
+
if (prior?.escalation?.maxRounds) records.maxRounds = Math.max(records.maxRounds, prior.escalation.maxRounds);
|
|
771
|
+
if (!/^[A-Za-z0-9 _-]+\.blend$/.test(args.scene ?? '')) deny('scene must be project-root .blend filename');
|
|
772
|
+
contained(this.projectPath(args.project), args.scene, true);
|
|
773
|
+
const reRegister = Boolean((prior?.escalation?.requested || prior?.reregistering) && prior.orchestrator === session && !prior.change);
|
|
774
|
+
if (s.challenge && Date.now() - s.challenge.created > 600000) s.challenge = null;
|
|
775
|
+
if (s.challenge) {
|
|
776
|
+
const c = s.challenge;
|
|
777
|
+
if (c.session !== session) deny(`registration for ${c.project} is pending in another session; finish it there or wait 10 minutes for it to expire before preparing again`);
|
|
778
|
+
const unchanged = c.project === args.project && c.goal === args.goal && c.scene === args.scene && this.head() === c.checkpoint && stable(this.snapshot(args.project)) === stable(c.snapshot) && stable(records) === stable(c.records);
|
|
779
|
+
if (unchanged) return { question: c.question, labels: ['Confirm registration', 'Cancel'], details: { project: c.project, workflow: c.records.workflow, review: c.records.review, scene: c.scene, goal: c.goal } };
|
|
780
|
+
s.challenge = null;
|
|
781
|
+
}
|
|
782
|
+
if (prior && !reRegister) deny('project already registered; use status to continue it, reregister a paused project, or escalate only after a capped review');
|
|
783
|
+
if (Object.values(s.projects).some((p) => p.project === args.project && p.orchestrator !== session)) deny('project owned by another orchestrator');
|
|
784
|
+
if (Object.values(s.projects).some((p) => p.orchestrator === session && p.project !== args.project)) deny('orchestrator already owns another project');
|
|
785
|
+
const snapshot = this.snapshot(args.project);
|
|
786
|
+
this.assertCommitted(args.project, snapshot);
|
|
787
|
+
const question = `Register ${args.project}, scene ${args.scene}, goal ${args.goal}, workflow ${records.workflow}, review ${records.review}? HARNESS REGISTER ${crypto.randomUUID()}`;
|
|
788
|
+
s.challenge = { ...args, records, session, question, snapshot, created: Date.now(), checkpoint: this.head(), reRegister, explicitReregister: Boolean(prior?.reregistering) };
|
|
789
|
+
return { question, labels: ['Confirm registration', 'Cancel'], details: { project: args.project, workflow: records.workflow, review: records.review, scene: args.scene, goal: args.goal } };
|
|
790
|
+
});
|
|
791
|
+
}
|
|
792
|
+
question(session, args, answers) {
|
|
793
|
+
return this.transaction((s) => {
|
|
794
|
+
const q = args.questions?.length === 1 ? args.questions[0] : null;
|
|
795
|
+
if (!q || q.multiple === true || q.custom === true || !Array.isArray(answers) || answers.length !== 1 || !Array.isArray(answers[0]) || answers[0].length !== 1 || !q.options?.some((o) => o.label === answers[0][0])) return;
|
|
796
|
+
if (s.challenge?.session === session && q.question === s.challenge.question) {
|
|
797
|
+
const c = s.challenge;
|
|
798
|
+
if (stable(q.options.map((o) => o.label)) !== stable(['Confirm registration', 'Cancel'])) deny('registration options changed');
|
|
799
|
+
if (Date.now() - c.created > 600000) deny('registration expired');
|
|
800
|
+
if (answers[0][0] !== 'Confirm registration') { s.challenge = null; return; }
|
|
801
|
+
const current = this.records(c.project, c.goal);
|
|
802
|
+
const prior = s.projects[c.project];
|
|
803
|
+
if (c.reRegister && prior?.escalation?.maxRounds) current.maxRounds = Math.max(current.maxRounds, prior.escalation.maxRounds);
|
|
804
|
+
if (stable(this.snapshot(c.project)) !== stable(c.snapshot) || stable(current) !== stable(c.records) || this.head() !== c.checkpoint) deny('registration facts changed');
|
|
805
|
+
const existing = s.projects[c.project];
|
|
806
|
+
if (c.reRegister) {
|
|
807
|
+
if (!existing || existing.orchestrator !== session || existing.change || (!existing.escalation?.requested && !existing.reregistering)) deny('explicit re-registration is no longer valid');
|
|
808
|
+
Object.assign(existing, { scene: `${c.project}/${c.scene}`, goal: c.goal, records: c.records, snapshot: c.snapshot, checkpoint: c.checkpoint, goalActive: false, change: null, complete: false, escalation: null, reregistering: false, lastRender: null });
|
|
809
|
+
} else {
|
|
810
|
+
s.projects[c.project] = { project: c.project, orchestrator: session, scene: `${c.project}/${c.scene}`, goal: c.goal, records: c.records, snapshot: c.snapshot, checkpoint: c.checkpoint, goalActive: false, change: null, history: [], complete: false, escalation: null };
|
|
811
|
+
}
|
|
812
|
+
s.challenge = null;
|
|
813
|
+
} else {
|
|
814
|
+
const p = Object.values(s.projects).find((p) => p.orchestrator === session && p.change?.approvalQuestion === q.question);
|
|
815
|
+
if (!p) return;
|
|
816
|
+
this.fresh(p);
|
|
817
|
+
if (stable(q.options.map((o) => o.label)) !== stable(['Approve change', 'Request revision', 'Pause'])) deny('approval options changed');
|
|
818
|
+
if (answers[0][0] === 'Approve change' && p.change.verdict !== 'PASS') deny('failed review cannot be approved; request revision or pause');
|
|
819
|
+
p.change.approval = ['Approve change', 'Request revision'].includes(answers[0][0]) ? { label: answers[0][0], evidence: p.change.evidenceKey } : null;
|
|
820
|
+
}
|
|
821
|
+
});
|
|
822
|
+
}
|
|
823
|
+
wire(action, payload = {}) {
|
|
824
|
+
return `blender_workflow {action:"${action}",payload:${JSON.stringify(JSON.stringify(payload))}}`;
|
|
825
|
+
}
|
|
826
|
+
nextStep(p, state) {
|
|
827
|
+
const preparePayload = { project: p.project, goal: p.goal, scene: path.basename(p.scene) };
|
|
828
|
+
if (p.escalation) return { phase: 'reregister', actor: 'orchestrator', action: 'prepare', command: this.wire('prepare', preparePayload), reason: 'Complete the exact registration question again with the escalated review cap.' };
|
|
829
|
+
if (p.complete) return { phase: 'complete', actor: 'orchestrator', action: 'goal_report', command: 'goal_report {status:"complete",reason:<evidence>}', reason: 'The goal has a clean checkpoint and applicable PASS; report completion.' };
|
|
830
|
+
if (!p.goalActive) {
|
|
831
|
+
if (p.change) return { phase: 'resume-goal', actor: 'orchestrator', action: 'goal', command: 'goal {action:"resume"}', reason: 'The Kilo goal is paused or cleared while a bounded change is open; resume before continuing.' };
|
|
832
|
+
return { phase: 'goal', actor: 'orchestrator', action: 'goal', command: 'goal {action:"start"|"resume",objective:<registered goal prompt>}', reason: 'Start or resume the registered goal before production work.' };
|
|
833
|
+
}
|
|
834
|
+
const c = p.change;
|
|
835
|
+
if (!c) {
|
|
836
|
+
const prior = p.history.at(-1);
|
|
837
|
+
const revision = Boolean(prior && (!['PASS', 'BOOTSTRAP', 'WIP', 'DOCUMENT'].includes(prior.verdict) || prior.approval === 'Request revision'));
|
|
838
|
+
if (revision && prior.chain >= p.records.maxRounds) return { phase: 'escalate', actor: 'orchestrator', action: 'escalate', command: this.wire('escalate', { reason: '<why another bounded round is necessary>', max_rounds: 4 }), reason: 'The review cap is exhausted; escalate explicitly before another revision.' };
|
|
839
|
+
return { phase: revision ? 'revision-begin' : 'begin', actor: 'orchestrator', action: 'begin', command: this.wire('begin', { outcome: '<one observable outcome>', objects: ['<exact object names>'], paths: ['<exact project-relative paths>'], ...(revision ? { revision: true } : {}) }), reason: revision ? 'The prior review did not pass; open a bounded revision with revision:true.' : 'Open one bounded change after the clean checkpoint.' };
|
|
840
|
+
}
|
|
841
|
+
if (c.pending) {
|
|
842
|
+
const ticket = state?.tickets?.[c.pending];
|
|
843
|
+
const operation = ticket?.operation ?? '<operation>';
|
|
844
|
+
const payload = ticket?.payload ?? {};
|
|
845
|
+
if (operation === 'apply') return { phase: 'apply', actor: 'blender-worker', action: 'blender_harness_execute', command: `blender_harness_execute {token:"${c.pending}",operation:"apply",payload:${JSON.stringify(JSON.stringify(payload))}}`, reason: 'Pass this one-shot apply ticket to blender-worker; the orchestrator must not execute it.' };
|
|
846
|
+
return { phase: operation, actor: 'orchestrator', action: 'blender_harness_execute', command: `blender_harness_execute {token:"${c.pending}",operation:"${operation}",payload:"{}"}`, reason: `Execute the pending ${operation} ticket.` };
|
|
847
|
+
}
|
|
848
|
+
if (c.wip) return { phase: 'checkpoint', actor: 'orchestrator', action: 'checkpoint', command: this.wire('checkpoint', {}), reason: 'The WIP commit is preserved; checkpoint it before another begin.' };
|
|
849
|
+
if (c.bootstrap && !c.saved) return { phase: 'bootstrap-save', actor: 'orchestrator', action: 'ticket', command: this.wire('ticket', { operation: 'save', payload: {} }), reason: 'Save the initial scene to the declared project-root .blend; never apply, seal, or grade the bootstrap save.' };
|
|
850
|
+
if (c.bootstrap && c.saved && !c.operations) {
|
|
851
|
+
const sceneCommitted = git(this.root, ['ls-tree', '-r', '--name-only', 'HEAD', '--', p.scene]).trim() === p.scene;
|
|
852
|
+
return sceneCommitted
|
|
853
|
+
? { phase: 'bootstrap-checkpoint', actor: 'orchestrator', action: 'checkpoint', command: this.wire('checkpoint', {}), reason: 'The bootstrap commit exists; checkpoint it before beginning scene work.' }
|
|
854
|
+
: { phase: 'bootstrap-commit', actor: 'orchestrator', action: 'commit', command: this.wire('commit', { message: '<bounded bootstrap message>' }), reason: 'Close the bootstrap save with the harness commit, then checkpoint; never seal or grade the initial scene.' };
|
|
855
|
+
}
|
|
856
|
+
if (!c.sealed && c.operations >= CHECKPOINT_EVERY && (c.failure || Date.now() > c.deadline)) return { phase: 'wip', actor: 'orchestrator', action: 'commit', command: this.wire('commit', { message: 'wip: preserve failed or expired change', mode: 'wip' }), reason: 'The apply budget is exhausted and the change cannot be repaired; preserve it with a WIP commit, then checkpoint and begin a fresh change.' };
|
|
857
|
+
if (!c.sealed && (c.failure || Date.now() > c.deadline)) return { phase: 'recover', actor: 'orchestrator', action: 'recover', command: this.wire('recover', { reopen: true }), reason: 'The change failed or its window expired; reopen it to repair, or commit {mode:"wip"} to preserve it.' };
|
|
858
|
+
if (!c.sealed && c.reopen && !c.documentOnly) return { phase: 'apply', actor: 'blender-worker', action: 'ticket', command: this.wire('ticket', { operation: 'apply', payload: '<operation or {operations:[...]}>' }), reason: 'The change was reopened; issue a repair apply batch and delegate it to blender-worker.' };
|
|
859
|
+
if (!c.sealed && c.nonSceneOnly && c.operations === 0) {
|
|
860
|
+
return this.head() === c.base
|
|
861
|
+
? { phase: 'commit', actor: 'orchestrator', action: 'commit', command: this.wire('commit', { message: '<one bounded non-scene message>' }), reason: 'This change contains only documentation, source-only scripts, references, or assets; commit it without sealing or review, then checkpoint.' }
|
|
862
|
+
: { phase: 'checkpoint', actor: 'orchestrator', action: 'checkpoint', command: this.wire('checkpoint', {}), reason: 'The non-scene commit is ready; checkpoint it before another begin.' };
|
|
863
|
+
}
|
|
864
|
+
if (!c.sealed && c.operations === 0) return { phase: 'apply', actor: 'blender-worker', action: 'ticket', command: this.wire('ticket', { operation: 'apply', payload: '<operation or {operations:[...]}>' }), reason: 'Seal requires at least one apply ticket; issue one operation or a batch of up to 10 and delegate it to blender-worker.' };
|
|
865
|
+
if (!c.sealed && c.operations > 0 && c.operations % 3 === 0 && (!c.wipRender || c.wipRender.operations !== c.operations)) return { phase: 'wip', actor: 'orchestrator', action: 'ticket', command: this.wire('ticket', { operation: 'wip', payload: {} }), reason: 'Every third apply requires a WIP render for a quick visual check.' };
|
|
866
|
+
if (!c.sealed && c.wipRender?.operations === c.operations && (!c.wipLook || c.wipLook.operations !== c.operations)) return { phase: 'wip-inspect', actor: 'orchestrator', action: 'blender_look', command: `blender_look {image:"${c.wipRender.path}"}`, reason: 'Inspect the exact WIP render before continuing or sealing.' };
|
|
867
|
+
if (!c.sealed && !c.readyToSeal) return { phase: 'apply', actor: 'blender-worker', action: 'ticket', command: this.wire('ticket', { operation: 'apply', payload: '<operation or {operations:[...]}>' }), reason: `Continue milestone "${c.milestone ?? c.outcome}", extend exact scope, or call finalize before sealing.` };
|
|
868
|
+
if (!c.sealed && (!c.wipRender || c.wipRender.operations !== c.operations)) return { phase: 'wip', actor: 'orchestrator', action: 'ticket', command: this.wire('ticket', { operation: 'wip', payload: {} }), reason: 'Render a WIP frame for the current apply state, then inspect it with blender_look.' };
|
|
869
|
+
if (!c.sealed && (!c.wipLook || c.wipLook.operations !== c.operations)) return { phase: 'wip-inspect', actor: 'orchestrator', action: 'blender_look', command: `blender_look {image:"${c.wipRender.path}"}`, reason: 'Inspect the exact WIP render before sealing the current apply state.' };
|
|
870
|
+
if (!c.sealed) return { phase: 'seal', actor: 'orchestrator', action: 'ticket', command: this.wire('ticket', { operation: 'seal', payload: {} }), reason: 'Save and seal the changed scene, then inspect the returned render.' };
|
|
871
|
+
if (!c.inspected) {
|
|
872
|
+
const pendingScene = Object.entries(c.renders ?? {}).find(([name]) => !c.inspections?.[name]);
|
|
873
|
+
const image = pendingScene?.[1] ?? c.renderPath;
|
|
874
|
+
return { phase: 'inspect', actor: 'orchestrator', action: 'blender_look', command: `blender_look {image:"${image}"}`, reason: `Inspect the exact sealed render${pendingScene ? ` for scene ${pendingScene[0]}` : ''} before review.` };
|
|
875
|
+
}
|
|
876
|
+
if (!c.verdict) {
|
|
877
|
+
if (p.records.review === 'Off') return { phase: 'self-review', actor: 'orchestrator', action: 'self-review', command: this.wire('self-review', { notes: '<what the sealed render proves>' }), reason: 'Record the required self-review after inspecting the sealed render.' };
|
|
878
|
+
const pending = p.records.roster.filter((g) => !c.graders[g.name]?.verdict);
|
|
879
|
+
const exhausted = pending.length > 0 && pending.every((g) => {
|
|
880
|
+
const assignment = c.graders[g.name];
|
|
881
|
+
return assignment && (assignment.attempt ?? 0) >= 2 && (!assignment.child || Date.now() > assignment.deadline);
|
|
882
|
+
});
|
|
883
|
+
const expired = pending.length > 0 && pending.every((g) => {
|
|
884
|
+
const assignment = c.graders[g.name];
|
|
885
|
+
return assignment && Date.now() > assignment.deadline;
|
|
886
|
+
});
|
|
887
|
+
if (!pending.length || exhausted || expired) {
|
|
888
|
+
return { phase: 'review', actor: 'orchestrator', action: 'review', command: this.wire('review', { notes: '<inspection notes>', reason: '<why no further grader retry is possible>', dispositions: '<finding dispositions>' }), reason: 'All available grader results are ready; aggregate the round and record any missing or expired grader as a tooling block.' };
|
|
889
|
+
}
|
|
890
|
+
const missing = pending[0].name;
|
|
891
|
+
return { phase: 'grader-ticket', actor: 'orchestrator', action: 'grader-ticket', command: this.wire('grader-ticket', { name: missing }), reason: 'Dispatch every configured grader, then aggregate their reports.' };
|
|
892
|
+
}
|
|
893
|
+
if (c.verdict === 'BLOCKED' && c.blockingKind === 'tooling') {
|
|
894
|
+
const blocked = p.records.roster.find((g) => c.graders[g.name]?.verdict === 'BLOCKED')?.name ?? '<grader>';
|
|
895
|
+
if ((c.graders[blocked]?.attempt ?? 0) >= 2) {
|
|
896
|
+
return { phase: 'preserve', actor: 'orchestrator', action: 'preserve', command: this.wire('preserve', { reason: '<why the tooling block cannot be resolved now>' }), reason: 'The grader retry budget is exhausted. Preserve the sealed evidence and task records as WIP, then checkpoint and pause or reregister.' };
|
|
897
|
+
}
|
|
898
|
+
return { phase: 'retry-grader', actor: 'orchestrator', action: 'grader-ticket', command: this.wire('grader-ticket', { name: blocked }), reason: 'This is a tooling/evidence block, not a design failure. Retry the blocked grader; do not open a scene revision.' };
|
|
899
|
+
}
|
|
900
|
+
if (p.records.workflow === 'Partially Autonomous' && !c.approval) return { phase: 'approval', actor: 'orchestrator', action: 'approval', command: this.wire('approval', {}), reason: 'Pause the goal and ask the exact approval question before committing this change.' };
|
|
901
|
+
if (c.verdict && this.reviewPending(p, c)) return { phase: 'commit', actor: 'orchestrator', action: 'commit', command: this.wire('commit', { message: '<one bounded conventional message>' }), reason: 'Commit the reviewed change and its harness artifacts.' };
|
|
902
|
+
return { phase: 'checkpoint', actor: 'orchestrator', action: 'checkpoint', command: this.wire('checkpoint', {}), reason: 'Verify the exact scoped commit and close the change before another begin.' };
|
|
903
|
+
}
|
|
904
|
+
status(p, state) {
|
|
905
|
+
const c = p.change;
|
|
906
|
+
const step = this.nextStep(p, state);
|
|
907
|
+
return { project: p.project, scene: p.scene, checkpoint: p.checkpoint, lastCommit: p.lastCommit ?? null, goalActive: p.goalActive, complete: p.complete, intake: p.intake ?? null, downloads: p.downloads ?? [], assetUse: Boolean(p.assetUse), assetCheck: p.assetCheck ?? null, exports: p.exports ?? [], blender: instanceStatus(this.home),
|
|
908
|
+
models: {
|
|
909
|
+
worker: (() => { try { return modelSummary(this.workerConfig()); } catch (error) { return { error: String(error.message).slice(0, 300) }; } })(),
|
|
910
|
+
graders: (p.records.roster ?? []).map(({ name, ...choice }) => ({ name, ...modelSummary(choice) })),
|
|
911
|
+
},
|
|
912
|
+
change: c ? { id: c.id, created: c.created ?? null, outcome: c.outcome, milestone: c.milestone ?? null, requirements: c.requirements ?? [], scenes: c.scenes ?? [], primaryScene: c.primaryScene ?? null, sceneRequirements: c.sceneRequirements ?? {}, renders: c.renders ?? {}, inspections: c.inspections ?? {}, final: Boolean(c.final), readyToSeal: Boolean(c.readyToSeal), paths: c.paths, saved: Boolean(c.saved), sealed: c.sealed, inspected: c.inspected, verdict: c.verdict, blockingKind: c.blockingKind ?? null, preserved: c.preserved ?? null, round: c.round, sceneHash: c.sceneHash, evidenceKey: c.evidenceKey, operations: c.operations, documentOnly: Boolean(c.documentOnly), nonSceneOnly: Boolean(c.nonSceneOnly), appliesRemaining: Math.max(0, CHECKPOINT_EVERY - c.operations), deadlineRemainingMs: Math.max(0, c.deadline - Date.now()), failure: Boolean(c.failure), reopen: Boolean(c.reopen), wip: Boolean(c.wip), wipRender: c.wipRender ?? null, wipLook: c.wipLook ?? null, commits: c.commits ?? [], pending: c.pending ? 'operation in progress' : null, pendingTask: c.pending ? (p.tasks?.find((task) => task.token === c.pending)?.id ?? null) : null, approved: Boolean(c.approval), graders: c.graders } : null,
|
|
913
|
+
next: step.reason, nextStep: step };
|
|
914
|
+
}
|
|
915
|
+
reregisterStatus(p) {
|
|
916
|
+
const base = { project: p.project, scene: p.scene, checkpoint: p.checkpoint, goalActive: p.goalActive, complete: p.complete, blender: instanceStatus(this.home), change: null, reregistering: p.reregistering };
|
|
917
|
+
try { this.records(p.project, p.goal); }
|
|
918
|
+
catch (error) {
|
|
919
|
+
return { ...base, next: `Fix the registration documents before preparing: ${error.message}`, nextStep: { phase: 'reregister-document', actor: 'orchestrator', action: 'bootstrap-document', command: this.wire('bootstrap-document', { project: p.project, path: '<invalid registration document>', content: '<complete format from capabilities.documentFormats>' }), reason: 'Fix registration documents through bootstrap-document while reregistering.' } };
|
|
920
|
+
}
|
|
921
|
+
try { this.assertCommitted(p.project, this.snapshot(p.project)); }
|
|
922
|
+
catch {
|
|
923
|
+
return { ...base, next: 'Commit the updated registration documents with bootstrap-commit.', nextStep: { phase: 'reregister-commit', actor: 'orchestrator', action: 'bootstrap-commit', command: this.wire('bootstrap-commit', { project: p.project, message: 'docs: update project registration' }), reason: 'Registration only accepts committed records.' } };
|
|
924
|
+
}
|
|
925
|
+
return { ...base, next: 'Call prepare and answer the new registration question.', nextStep: { phase: 'reregister-prepare', actor: 'orchestrator', action: 'prepare', command: this.wire('prepare', { project: p.project, goal: p.goal, scene: path.basename(p.scene) }), reason: 'The updated records are committed; confirm the new registration.' } };
|
|
926
|
+
}
|
|
927
|
+
bootstrapStatus(session, project, state) {
|
|
928
|
+
if (!/^Blender\/[A-Za-z0-9][A-Za-z0-9 _-]*$/.test(project ?? '')) deny('project must be Blender/<Name>');
|
|
929
|
+
if (!fs.existsSync(path.join(this.root, ...project.split('/')))) {
|
|
930
|
+
const name = project.slice('Blender/'.length);
|
|
931
|
+
return { project, registered: false, next: 'Scaffold the project folder with the workspace CLI.', nextStep: { phase: 'scaffold', actor: 'orchestrator', action: 'scaffold', command: `node .kilo/blender-project-harness/tools/harness/cli.mjs scaffold --name "${name}"`, reason: 'The project folder does not exist yet.' } };
|
|
932
|
+
}
|
|
933
|
+
this.projectPath(project);
|
|
934
|
+
const existing = Object.values(state.projects).find((p) => p.project === project);
|
|
935
|
+
if (existing) {
|
|
936
|
+
if (existing.orchestrator !== session) return { project, registered: true, next: 'Project is registered to another orchestrator session.', nextStep: { phase: 'owned', actor: 'other-session', action: null, command: null, reason: 'Do not take over a registered project.' } };
|
|
937
|
+
return this.status(existing, state);
|
|
938
|
+
}
|
|
939
|
+
const challenge = state.challenge;
|
|
940
|
+
if (challenge?.project === project) {
|
|
941
|
+
if (challenge.session !== session) return { project, registered: false, next: 'Another session is registering this project; wait for it to finish or expire.', nextStep: { phase: 'registration-pending', actor: 'other-session', action: null, command: null, reason: 'Do not create a second registration challenge.' } };
|
|
942
|
+
return { project, registered: false, next: 'Answer the exact pending registration question, then start or resume the goal.', nextStep: { phase: 'registration', actor: 'orchestrator', action: 'question', command: 'question {questions:[<returned registration question>]}', reason: 'Only the runtime answer grants registration.' } };
|
|
943
|
+
}
|
|
944
|
+
const base = this.projectPath(project);
|
|
945
|
+
const goalsDirectory = path.join(base, 'Documents', 'Goals');
|
|
946
|
+
const goals = fs.existsSync(goalsDirectory)
|
|
947
|
+
? fs.readdirSync(goalsDirectory).filter((name) => /^\d+-[a-z0-9-]+\.md$/.test(name)).sort()
|
|
948
|
+
: [];
|
|
949
|
+
const goal = goals.length === 1 ? `Documents/Goals/${goals[0]}` : null;
|
|
950
|
+
if (!goal && goals.length) {
|
|
951
|
+
return { project, registered: false, next: 'Select one existing goal path and call prepare.', nextStep: { phase: 'prepare', actor: 'orchestrator', action: 'prepare', command: this.wire('prepare', { project, goal: `Documents/Goals/${goals[0]}`, scene: '<project-root>.blend' }), reason: 'Registration accepts one existing goal; remove or archive extra goal documents.' } };
|
|
952
|
+
}
|
|
953
|
+
if (!goal) {
|
|
954
|
+
return { project, registered: false, next: 'Create and fill Documents/Goals/01-<slug>.md through bootstrap-document.', nextStep: { phase: 'goal-document', actor: 'orchestrator', action: 'bootstrap-document', command: this.wire('bootstrap-document', { project, path: 'Documents/Goals/01-<slug>.md', content: '<filled goal markdown>' }), reason: 'Registration requires exactly one current goal document.' } };
|
|
955
|
+
}
|
|
956
|
+
try { this.records(project, goal); }
|
|
957
|
+
catch (error) {
|
|
958
|
+
return { project, registered: false, next: `Fix the project records through bootstrap-document: ${error.message}`, nextStep: { phase: 'bootstrap-document', actor: 'orchestrator', action: 'bootstrap-document', command: this.wire('bootstrap-document', { project, path: '<invalid project document>', content: '<complete format from capabilities.documentFormats>' }), reason: 'Document format and confirmed records must pass before registration.' } };
|
|
959
|
+
}
|
|
960
|
+
try { this.assertCommitted(project, this.snapshot(project)); }
|
|
961
|
+
catch (error) {
|
|
962
|
+
return { project, registered: false, next: 'Commit the exact scaffold documents and filled goal with bootstrap-commit.', nextStep: { phase: 'bootstrap-commit', actor: 'orchestrator', action: 'bootstrap-commit', command: this.wire('bootstrap-commit', { project, message: 'docs: scaffold <project>' }), reason: 'Registration only accepts a clean committed scaffold.' } };
|
|
963
|
+
}
|
|
964
|
+
return { project, registered: false, next: 'Call prepare and answer its exact registration question.', nextStep: { phase: 'prepare', actor: 'orchestrator', action: 'prepare', command: this.wire('prepare', { project, goal, scene: '<project-root>.blend' }), reason: 'The scaffold is committed and valid.' } };
|
|
965
|
+
}
|
|
966
|
+
documentRead(session, args, parentSession = null) {
|
|
967
|
+
return this.transaction((s) => {
|
|
968
|
+
if (Object.keys(args).some((key) => !['project', 'path', 'section'].includes(key))) deny('document read accepts project, path and section only');
|
|
969
|
+
if (typeof args.path !== 'string' || !args.path.trim()) deny('document read requires a project-relative path');
|
|
970
|
+
if (args.section !== undefined && typeof args.section !== 'string') deny('document section must be a heading string');
|
|
971
|
+
const assignment = this.graderAssignment(s, session);
|
|
972
|
+
const owned = Object.values(s.projects).find((p) => p.orchestrator === session);
|
|
973
|
+
const parentOwned = parentSession ? Object.values(s.projects).find((p) => p.orchestrator === parentSession) : null;
|
|
974
|
+
const registered = Object.values(s.projects).find((p) => p.project === args.project);
|
|
975
|
+
if (registered && registered.orchestrator !== session && registered.orchestrator !== parentSession && !assignment) deny('document read requires the owning session');
|
|
976
|
+
const p = assignment?.p ?? owned ?? parentOwned ?? registered ?? null;
|
|
977
|
+
const project = p?.project ?? args.project;
|
|
978
|
+
if (!project) deny('document read requires project before registration');
|
|
979
|
+
if (p && args.project && args.project !== p.project) deny('document read project mismatch');
|
|
980
|
+
const base = this.projectPath(project);
|
|
981
|
+
const { full, clean } = documentPath(base, args.path);
|
|
982
|
+
if (!/\.md$/i.test(clean)) deny('document read accepts project markdown only');
|
|
983
|
+
if (assignment) {
|
|
984
|
+
const allowed = new Set([...Object.keys(assignment.p.records.specs),
|
|
985
|
+
`${assignment.p.project}/README.md`, `${assignment.p.project}/Documents/ASSETS.md`,
|
|
986
|
+
`${assignment.p.project}/Documents/DECISIONS.md`, `${assignment.p.project}/Documents/REVIEW.md`,
|
|
987
|
+
`${assignment.p.project}/${TASKSTRACKER_DOCUMENT}`, `${assignment.p.project}/Documents/README.md`, assignment.g.report]);
|
|
988
|
+
const reportPrefix = 'Documents/Reviews/';
|
|
989
|
+
const reportSuffix = `/${assignment.name}/REPORT.md`;
|
|
990
|
+
const round = clean.startsWith(reportPrefix) && clean.endsWith(reportSuffix)
|
|
991
|
+
? clean.slice(reportPrefix.length, clean.length - reportSuffix.length)
|
|
992
|
+
: '';
|
|
993
|
+
const ownPriorReport = Boolean(round) && !round.includes('/');
|
|
994
|
+
const reference = `${project}/Documents/References/`;
|
|
995
|
+
if (!allowed.has(`${project}/${clean}`) && !ownPriorReport && !`${project}/${clean}`.startsWith(reference)) deny('grader document read outside assigned spec documents');
|
|
996
|
+
}
|
|
997
|
+
if (!fs.existsSync(full)) deny(`project document missing: ${clean}`);
|
|
998
|
+
const content = fs.readFileSync(full, 'utf8');
|
|
999
|
+
const outline = documentOutline(clean, content);
|
|
1000
|
+
const result = { project, path: clean, ...outline };
|
|
1001
|
+
if (!outline.valid) result.next = 'Invalid document structure; fix the diagnostics through blender_document write using capabilities.documentFormats.';
|
|
1002
|
+
if (!args.section) return result;
|
|
1003
|
+
const section = documentSection(clean, content, args.section);
|
|
1004
|
+
if (!section) deny(`document section not found: ${args.section}; available: ${outline.sections.map((item) => item.heading).join(', ')}`);
|
|
1005
|
+
return { ...result, section };
|
|
1006
|
+
});
|
|
1007
|
+
}
|
|
1008
|
+
documentWrite(session, args) {
|
|
1009
|
+
const assignment = this.transaction((s) => this.graderAssignment(s, session));
|
|
1010
|
+
if (!assignment) {
|
|
1011
|
+
const { project: _project, ...documentArgs } = args;
|
|
1012
|
+
return this.command(session, 'document', documentArgs);
|
|
1013
|
+
}
|
|
1014
|
+
return this.transaction((s) => {
|
|
1015
|
+
const current = this.graderAssignment(s, session);
|
|
1016
|
+
if (!current) deny('grader document assignment missing');
|
|
1017
|
+
const { p, g } = current;
|
|
1018
|
+
if (Object.keys(args).some((key) => !['project', 'path', 'content'].includes(key))) deny('grader document write accepts project, path and content only');
|
|
1019
|
+
if (args.project && args.project !== p.project) deny('grader document write project mismatch');
|
|
1020
|
+
if (g.verdict || g.error) deny('grader report already finalized');
|
|
1021
|
+
if (typeof args.path !== 'string' || typeof args.content !== 'string' || args.content.length > 1_000_000) deny('grader report path and bounded markdown content required');
|
|
1022
|
+
const base = this.projectPath(p.project);
|
|
1023
|
+
const { full, clean } = documentPath(base, args.path);
|
|
1024
|
+
if (`${p.project}/${clean}` !== g.report) deny('grader may write only its assigned report path');
|
|
1025
|
+
try { validateDocument(clean, args.content); } catch (error) { deny(error.message); }
|
|
1026
|
+
const verdicts = [...args.content.matchAll(/^- Verdict: (PASS|CHANGES_REQUIRED|BLOCKED)\s*$/gm)];
|
|
1027
|
+
if (verdicts.length !== 1) deny('grader report requires exactly one - Verdict: PASS|CHANGES_REQUIRED|BLOCKED line');
|
|
1028
|
+
if (!args.content.includes(p.change.sceneHash) || !args.content.includes(p.change.evidenceKey)) deny('grader report must cite the assigned scene hash and evidence key');
|
|
1029
|
+
fs.mkdirSync(path.dirname(full), { recursive: true });
|
|
1030
|
+
fs.writeFileSync(full, args.content);
|
|
1031
|
+
return { project: p.project, path: g.report, verdict: verdicts[0][1], bytes: args.content.length, next: 'Return the report facts; the orchestrator validates and aggregates.' };
|
|
1032
|
+
});
|
|
1033
|
+
}
|
|
1034
|
+
reviewPending(p, c) {
|
|
1035
|
+
const paths = this.reviewPaths(p, c);
|
|
1036
|
+
return Boolean(gitText(this.root, ['status', '--porcelain', '--', ...paths]).trim());
|
|
1037
|
+
}
|
|
1038
|
+
reviewPaths(p, c) {
|
|
1039
|
+
return [c.renderPath, ...Object.keys(c.reports), `${p.project}/Documents/Reviews/${c.round}/manifest.json`, `${p.project}/Documents/Reviews/README.md`, `${p.project}/Documents/REVIEW.md`];
|
|
1040
|
+
}
|
|
1041
|
+
updateReviewIndex(p, c) {
|
|
1042
|
+
const relative = `${p.project}/Documents/Reviews/README.md`;
|
|
1043
|
+
const full = path.join(this.root, ...relative.split('/'));
|
|
1044
|
+
if (!fs.existsSync(full)) return;
|
|
1045
|
+
const raw = fs.readFileSync(full, 'utf8');
|
|
1046
|
+
const eol = raw.includes('\r\n') ? '\r\n' : '\n';
|
|
1047
|
+
const lines = raw.split(/\r?\n/);
|
|
1048
|
+
const header = lines.findIndex((line) => /^\|\s*Round\s*\|/.test(line));
|
|
1049
|
+
if (header < 0 || lines.some((line) => line.startsWith(`| ${c.round} |`))) return;
|
|
1050
|
+
const utc = c.round.replace(/^R\d+-/, '');
|
|
1051
|
+
const row = `| ${c.round} | ${utc} | ${c.sceneHash.slice(0, 12)} | ${p.records.review} ${c.final ? 'final' : 'milestone'} | ${Object.keys(c.graders).join(', ')} | ${c.verdict} | [${c.round}/REVIEW.md](${c.round}/REVIEW.md) |`;
|
|
1052
|
+
let end = header + 2;
|
|
1053
|
+
while (end < lines.length && lines[end].startsWith('|')) end += 1;
|
|
1054
|
+
const placeholder = lines.findIndex((line, index) => index > header && /^\|\s*—\s*\|/.test(line));
|
|
1055
|
+
if (placeholder >= 0 && placeholder < end) lines[placeholder] = row;
|
|
1056
|
+
else lines.splice(end, 0, row);
|
|
1057
|
+
fs.writeFileSync(full, lines.join(eol));
|
|
1058
|
+
}
|
|
1059
|
+
commitProgress(p, c, task) {
|
|
1060
|
+
const expectedHead = c.commits?.at(-1) ?? c.base;
|
|
1061
|
+
if (this.head() !== expectedHead) deny('apply commit must directly follow the prior change commit');
|
|
1062
|
+
const files = committableFiles(this.root, [...new Set([...c.paths, p.scene, this.taskDocument(p), task.taskDoc, task.summaryDoc])]
|
|
1063
|
+
.filter((name) => typeof name === 'string' && fs.existsSync(path.join(this.root, ...name.split('/')))));
|
|
1064
|
+
if (files.length) git(this.root, ['add', '--', ...files]);
|
|
1065
|
+
if (!gitText(this.root, ['diff', '--cached', '--name-only'])) deny('no apply files changed');
|
|
1066
|
+
git(this.root, ['commit', '-m', `apply: ${task.id} ${String(c.outcome).slice(0, 120)}`]);
|
|
1067
|
+
c.commits ??= [];
|
|
1068
|
+
c.commits.push(this.head());
|
|
1069
|
+
p.lastCommit = this.head();
|
|
1070
|
+
}
|
|
1071
|
+
commitTaskArtifacts(p, c, message, { manifest = true } = {}) {
|
|
1072
|
+
const files = [this.taskDocument(p), ...this.taskFiles(p, c), ...(manifest ? [c.manifestPath] : [])]
|
|
1073
|
+
.filter((name) => typeof name === 'string' && fs.existsSync(path.join(this.root, ...name.split('/'))));
|
|
1074
|
+
if (!files.length) return null;
|
|
1075
|
+
const dirty = gitText(this.root, ['status', '--porcelain', '--untracked-files=all', '--', ...files]);
|
|
1076
|
+
if (!dirty) return null;
|
|
1077
|
+
if (gitText(this.root, ['diff', '--cached', '--name-only'])) deny('index must be clean before task metadata commit');
|
|
1078
|
+
git(this.root, ['add', '--', ...files]);
|
|
1079
|
+
if (!gitText(this.root, ['diff', '--cached', '--name-only'])) return null;
|
|
1080
|
+
git(this.root, ['commit', '-m', String(message).slice(0, 200)]);
|
|
1081
|
+
c.commits ??= [];
|
|
1082
|
+
c.commits.push(this.head());
|
|
1083
|
+
p.lastCommit = this.head();
|
|
1084
|
+
return p.lastCommit;
|
|
1085
|
+
}
|
|
1086
|
+
commitShellApproval(session, project, record) {
|
|
1087
|
+
return this.transaction((s) => {
|
|
1088
|
+
if (!record || !/^[a-f0-9-]{36}$/i.test(record.id ?? '') || record.status !== 'approved'
|
|
1089
|
+
|| typeof record.command !== 'string' || !record.command.trim()
|
|
1090
|
+
|| typeof record.reasoning !== 'string' || record.reasoning.trim().length < 8) {
|
|
1091
|
+
deny('invalid shell approval record');
|
|
1092
|
+
}
|
|
1093
|
+
let p = null;
|
|
1094
|
+
let base;
|
|
1095
|
+
if (project) {
|
|
1096
|
+
p = Object.values(s.projects).find((item) => item.project === project);
|
|
1097
|
+
if (!p || p.orchestrator !== session) deny('shell approval project is not owned by this session');
|
|
1098
|
+
base = this.projectPath(project);
|
|
1099
|
+
} else {
|
|
1100
|
+
base = this.root;
|
|
1101
|
+
}
|
|
1102
|
+
const relativeDir = project ? 'Documents/Commands' : 'Commands';
|
|
1103
|
+
const recordName = `${relativeDir}/${record.id}.md`;
|
|
1104
|
+
const indexPath = `${relativeDir}/COMMANDS.md`;
|
|
1105
|
+
const full = contained(base, recordName, true);
|
|
1106
|
+
fs.mkdirSync(path.dirname(full), { recursive: true });
|
|
1107
|
+
const text = [
|
|
1108
|
+
`# Shell Command ${record.id}`,
|
|
1109
|
+
'',
|
|
1110
|
+
`- Status: approved`,
|
|
1111
|
+
`- Scope: ${project ?? 'workspace'}`,
|
|
1112
|
+
`- Session: ${record.session}`,
|
|
1113
|
+
`- Created: ${record.created}`,
|
|
1114
|
+
`- Approved: ${record.approvedAt}`,
|
|
1115
|
+
`- CWD: ${record.cwd}`,
|
|
1116
|
+
'',
|
|
1117
|
+
'## Command',
|
|
1118
|
+
'',
|
|
1119
|
+
'```powershell',
|
|
1120
|
+
record.command,
|
|
1121
|
+
'```',
|
|
1122
|
+
'',
|
|
1123
|
+
'## Reasoning',
|
|
1124
|
+
'',
|
|
1125
|
+
record.reasoning.trim(),
|
|
1126
|
+
'',
|
|
1127
|
+
].join('\n');
|
|
1128
|
+
fs.writeFileSync(full, text);
|
|
1129
|
+
const indexFull = contained(base, indexPath, true);
|
|
1130
|
+
const header = '# Shell Command Approvals\n\n| Command | Approved | Shell command | Reasoning |\n|---|---|---|---|\n';
|
|
1131
|
+
const existing = fs.existsSync(indexFull) ? fs.readFileSync(indexFull, 'utf8') : header;
|
|
1132
|
+
const escape = (value) => String(value ?? '').replaceAll('|', '\\|').replace(/[\r\n]+/g, ' ').slice(0, 160);
|
|
1133
|
+
const row = `| [${record.id}](${record.id}.md) | ${escape(record.approvedAt)} | ${escape(record.command)} | ${escape(record.reasoning)} |\n`;
|
|
1134
|
+
const index = existing.includes(`(${record.id}.md)`) ? existing : `${existing.replace(/\s*$/, '\n')}${row}`;
|
|
1135
|
+
fs.writeFileSync(indexFull, index);
|
|
1136
|
+
const files = [project ? `${project}/${recordName}` : recordName, project ? `${project}/${indexPath}` : indexPath];
|
|
1137
|
+
git(this.root, ['add', '--', ...files]);
|
|
1138
|
+
git(this.root, ['commit', '--only', '-m', `chore: record shell approval ${record.id.slice(0, 8)}`, '--', ...files]);
|
|
1139
|
+
const commit = this.head();
|
|
1140
|
+
if (p) {
|
|
1141
|
+
if (p.change) {
|
|
1142
|
+
p.change.commits ??= [];
|
|
1143
|
+
p.change.commits.push(commit);
|
|
1144
|
+
} else {
|
|
1145
|
+
p.checkpoint = commit;
|
|
1146
|
+
p.snapshot = this.snapshot(p.project);
|
|
1147
|
+
}
|
|
1148
|
+
p.lastCommit = commit;
|
|
1149
|
+
}
|
|
1150
|
+
return { path: recordName, commit, project: project ?? null };
|
|
1151
|
+
});
|
|
1152
|
+
}
|
|
1153
|
+
autoCommit(p, c, message, { review = true } = {}) {
|
|
1154
|
+
const expectedHead = c.commits?.at(-1) ?? c.base;
|
|
1155
|
+
if (this.head() !== expectedHead) deny('commit must directly follow the prior change commit');
|
|
1156
|
+
const artifacts = review ? this.reviewPaths(p, c) : [];
|
|
1157
|
+
const declared = new Set(c.paths);
|
|
1158
|
+
const taskDocument = this.taskDocument(p);
|
|
1159
|
+
const tracked = new Set(git(this.root, ['ls-files', '-z', '--', p.project]).split('\0').filter(Boolean));
|
|
1160
|
+
const files = committableFiles(this.root, [...new Set([...c.paths, p.scene, taskDocument, ...this.taskFiles(p, c), ...artifacts])]
|
|
1161
|
+
.filter((name) => typeof name === 'string' && (
|
|
1162
|
+
fs.existsSync(path.join(this.root, ...name.split('/')))
|
|
1163
|
+
|| (declared.has(name) && name !== p.scene && name !== taskDocument && tracked.has(name))
|
|
1164
|
+
)));
|
|
1165
|
+
const allowed = new Set(files);
|
|
1166
|
+
const staged = gitText(this.root, ['diff', '--cached', '--name-only']).split(/\r?\n/).filter(Boolean);
|
|
1167
|
+
if (staged.some((name) => !allowed.has(name))) deny('index contains files outside the bounded change');
|
|
1168
|
+
if (files.length) git(this.root, ['add', '--', ...files]);
|
|
1169
|
+
externalBinaryPolicy(this.root, p.project, git(this.root, ['diff', '--cached', '--name-only', '--', p.project]).split(/\r?\n/).filter(Boolean));
|
|
1170
|
+
if (!gitText(this.root, ['diff', '--cached', '--name-only'])) deny('no review files changed');
|
|
1171
|
+
git(this.root, ['commit', '-m', message]);
|
|
1172
|
+
c.commits ??= [];
|
|
1173
|
+
c.commits.push(this.head());
|
|
1174
|
+
p.lastCommit = this.head();
|
|
1175
|
+
return p.lastCommit;
|
|
1176
|
+
}
|
|
1177
|
+
command(session, action, args = {}) {
|
|
1178
|
+
return this.transaction((s) => {
|
|
1179
|
+
if (action === 'status') {
|
|
1180
|
+
const projects = Object.values(s.projects).filter((p) => p.orchestrator === session);
|
|
1181
|
+
if (projects.length === 1) return projects[0].reregistering ? this.reregisterStatus(projects[0]) : this.status(projects[0], s);
|
|
1182
|
+
if (projects.length > 1) deny('session owns multiple projects; inspect workspace state before continuing');
|
|
1183
|
+
if (args.project) return this.bootstrapStatus(session, args.project, s);
|
|
1184
|
+
const pending = s.challenge?.session === session
|
|
1185
|
+
? 'Answer the pending registration question, then start or resume the goal'
|
|
1186
|
+
: s.challenge ? `Another session is registering ${s.challenge.project}; wait for it to finish or expire`
|
|
1187
|
+
: 'Complete and commit scaffold docs, then call prepare and answer its registration question';
|
|
1188
|
+
return { project: null, scene: null, checkpoint: this.head(), goalActive: false, complete: false, change: null, blender: instanceStatus(this.home), next: pending, nextStep: { phase: 'unregistered', actor: 'orchestrator', action: 'status', command: 'blender_workflow {action:"status",payload:"{\\"project\\":\\"Blender/<Name>\\"}"}', reason: pending } };
|
|
1189
|
+
}
|
|
1190
|
+
if (action === 'reclaim') {
|
|
1191
|
+
if (typeof args.project !== 'string' || !args.project.trim() || typeof args.reason !== 'string' || !args.reason.trim() || args.reason.length > 1000) deny('reclaim requires project and one bounded reason');
|
|
1192
|
+
const target = s.projects[args.project];
|
|
1193
|
+
if (!target) deny('project not found');
|
|
1194
|
+
if (target.orchestrator === session) return this.status(target, s);
|
|
1195
|
+
const stale = !target.change || target.change.deadline < Date.now();
|
|
1196
|
+
if (!stale) deny('project owner has a live change window; pause it before reclaiming');
|
|
1197
|
+
const from = target.orchestrator;
|
|
1198
|
+
target.orchestrator = session;
|
|
1199
|
+
target.goalActive = true;
|
|
1200
|
+
target.paused = null;
|
|
1201
|
+
target.reclaimed = { from, at: new Date().toISOString(), reason: args.reason.trim() };
|
|
1202
|
+
return this.status(target, s);
|
|
1203
|
+
}
|
|
1204
|
+
const p = this.owner(s, session);
|
|
1205
|
+
if (action === 'pause') {
|
|
1206
|
+
if (Object.keys(args).some((key) => key !== 'reason') || typeof args.reason !== 'string' || !args.reason.trim() || args.reason.length > 1000) deny('pause requires one bounded reason');
|
|
1207
|
+
if (p.change) deny('close the open change with commit/checkpoint or WIP before pausing the project');
|
|
1208
|
+
p.goalActive = false;
|
|
1209
|
+
p.paused = { at: new Date().toISOString(), reason: args.reason.trim() };
|
|
1210
|
+
return this.status(p, s);
|
|
1211
|
+
}
|
|
1212
|
+
if (action === 'reregister') {
|
|
1213
|
+
if (Object.keys(args).some((key) => key !== 'reason') || typeof args.reason !== 'string' || !args.reason.trim() || args.reason.length > 1000) deny('reregister requires one bounded reason');
|
|
1214
|
+
if (p.reregistering) return this.reregisterStatus(p);
|
|
1215
|
+
if (p.change) deny('checkpoint or preserve the open change before re-registration');
|
|
1216
|
+
p.reregistering = { requested: Date.now(), reason: args.reason.trim() };
|
|
1217
|
+
p.goalActive = false;
|
|
1218
|
+
p.complete = false;
|
|
1219
|
+
p.escalation = null;
|
|
1220
|
+
p.lastRender = null;
|
|
1221
|
+
p.lastRenders = null;
|
|
1222
|
+
p.assetCheck = null;
|
|
1223
|
+
p.exports = [];
|
|
1224
|
+
return this.reregisterStatus(p);
|
|
1225
|
+
}
|
|
1226
|
+
const c = p.change;
|
|
1227
|
+
const allowRosterDrift = Boolean((c && ['review','self-review','approval','preserve','commit','checkpoint','seal','grader-fail'].includes(action)) || ['goal-active','goal-inactive','goal-complete'].includes(action));
|
|
1228
|
+
this.fresh(p, { allowRosterDrift });
|
|
1229
|
+
if (action === 'goal-active') {
|
|
1230
|
+
if (p.complete || (args.objective !== undefined && normalizeGoal(args.objective) !== normalizeGoal(p.records.goalText))) deny('goal must match registered prompt');
|
|
1231
|
+
p.goalActive = true;
|
|
1232
|
+
} else if (action === 'goal-inactive') {
|
|
1233
|
+
p.goalActive = false;
|
|
1234
|
+
} else if (action === 'goal-complete') {
|
|
1235
|
+
const last = p.history.at(-1);
|
|
1236
|
+
if (!p.goalActive || c || !last || !['PASS', 'DOCUMENT'].includes(last.verdict) || last.approval === 'Request revision' || this.hash(p.scene) !== last.sceneHash || !p.lastRender || this.hash(p.lastRender.path) !== p.lastRender.hash) deny('completion requires active goal, checkpoint, inspected render, unchanged scene and applicable PASS');
|
|
1237
|
+
if (p.lastRenders && Object.values(p.lastRenders).some((render) => !fs.existsSync(path.join(this.root, ...render.path.split('/'))) || this.hash(render.path) !== render.hash)) deny('completion requires every sealed scene render to remain unchanged');
|
|
1238
|
+
if (p.assetUse && (!p.assetCheck || p.assetCheck.sceneHash !== last.sceneHash)) deny('completion requires check_assets on the final unchanged scene; import or repair the asset receipt first');
|
|
1239
|
+
for (const receipt of p.exports ?? []) {
|
|
1240
|
+
const full = path.join(this.root, ...receipt.path.split('/'));
|
|
1241
|
+
if (!fs.existsSync(full) || this.hash(receipt.path) !== receipt.sha256) deny(`completion requires a valid export receipt: ${receipt.path}`);
|
|
1242
|
+
}
|
|
1243
|
+
if (!p.history.some((item) => item.final && item.verdict === 'PASS')) deny('completion requires a final full-spec PASS after milestone reviews');
|
|
1244
|
+
this.assertCommitted(p.project, this.snapshot(p.project));
|
|
1245
|
+
p.complete = true;
|
|
1246
|
+
this.commitTaskTrackerCompletion(p);
|
|
1247
|
+
} else if (action === 'escalate') {
|
|
1248
|
+
const prior = p.history.at(-1);
|
|
1249
|
+
if (c || p.complete || p.escalation || !prior || prior.verdict === 'BOOTSTRAP' || prior.chain < p.records.maxRounds || typeof args.reason !== 'string' || !args.reason.trim() || args.reason.length > 1000) deny('explicit escalation requires a completed capped review and a bounded reason');
|
|
1250
|
+
const maxRounds = args.max_rounds ?? p.records.maxRounds;
|
|
1251
|
+
if (!Number.isInteger(maxRounds) || maxRounds < 1 || maxRounds > 4 || maxRounds < p.records.maxRounds) deny('escalation may extend max_rounds up to 4');
|
|
1252
|
+
p.escalation = { requested: Date.now(), reason: args.reason, maxRounds };
|
|
1253
|
+
} else if (action === 'begin') {
|
|
1254
|
+
if (c || p.complete || p.escalation || !p.goalActive) deny('one open logical change per project; prior checkpoint and active goal required');
|
|
1255
|
+
if (typeof args.outcome !== 'string' || !args.outcome.trim() || args.outcome.length > 1000 || !Array.isArray(args.objects) || args.objects.length > 200 || args.objects.some((n) => typeof n !== 'string' || !/^[\x20-\x7e]{1,100}$/.test(n))) deny('bounded outcome and exact object names required');
|
|
1256
|
+
if (args.final !== undefined && typeof args.final !== 'boolean') deny('final must be boolean');
|
|
1257
|
+
const milestone = args.final === true ? 'final' : args.milestone ?? args.outcome;
|
|
1258
|
+
if (typeof milestone !== 'string' || !milestone.trim() || milestone.length > 200) deny('milestone must be a bounded non-empty string');
|
|
1259
|
+
if (args.requirements !== undefined && (!Array.isArray(args.requirements) || args.requirements.length > 50 || args.requirements.some((item) => typeof item !== 'string' || !item.trim()))) deny('requirements must be an array of non-empty strings');
|
|
1260
|
+
if (args.scenes !== undefined && (!Array.isArray(args.scenes) || args.scenes.length > MAX_CHANGE_SCENES || args.scenes.some((name) => !validSceneName(name)) || new Set(args.scenes).size !== args.scenes.length)) deny(`scenes must be up to ${MAX_CHANGE_SCENES} unique printable scene names`);
|
|
1261
|
+
if (args.sceneRequirements !== undefined) {
|
|
1262
|
+
if (!isPlainObject(args.sceneRequirements)) deny('sceneRequirements must be an object');
|
|
1263
|
+
for (const [requirement, scenes] of Object.entries(args.sceneRequirements)) {
|
|
1264
|
+
if (!args.requirements?.includes(requirement)) deny(`sceneRequirements key is not a declared requirement: ${requirement}`);
|
|
1265
|
+
if (!Array.isArray(scenes) || !scenes.length || scenes.some((name) => !validSceneName(name))) deny(`sceneRequirements.${requirement} must list printable scene names`);
|
|
1266
|
+
}
|
|
1267
|
+
}
|
|
1268
|
+
if (!Array.isArray(args.paths) || !args.paths.length || args.paths.length > 100) deny('exact project-relative file paths required');
|
|
1269
|
+
const paths = [...new Set(args.paths.map((name) => {
|
|
1270
|
+
const full = contained(this.projectPath(p.project), name, true);
|
|
1271
|
+
const clean = path.relative(this.projectPath(p.project), full).replaceAll('\\', '/');
|
|
1272
|
+
if (/\.(?:py|js|mjs|ts|cmd|ps1|exe|dll|blend\d*)$/i.test(clean) && !/^scripts\/.*\.(?:py|pyw)$/i.test(clean) && `${p.project}/${clean}` !== p.scene) deny('production scope cannot add executable files except source-only scripts');
|
|
1273
|
+
return `${p.project}/${clean}`;
|
|
1274
|
+
}))];
|
|
1275
|
+
if (stable(this.snapshot(p.project)) !== stable(p.snapshot)) deny('project changed since checkpoint');
|
|
1276
|
+
this.assertCommitted(p.project, p.snapshot);
|
|
1277
|
+
if (gitText(this.root, ['diff', '--cached', '--name-only'])) deny('index must be clean');
|
|
1278
|
+
const prior = p.history.at(-1);
|
|
1279
|
+
if (args.revision && !prior) deny('revision requires a prior completed review');
|
|
1280
|
+
if (args.revision && prior?.verdict === 'PASS' && prior.approval !== 'Request revision') deny('revision requires a failed review or recorded revision request');
|
|
1281
|
+
if (args.revision) {
|
|
1282
|
+
const review = `${p.project}/Documents/Reviews/${prior.round}/REVIEW.md`;
|
|
1283
|
+
if (!git(this.root, ['ls-files', '--', review]).trim()) deny('revision requires the committed verdict document from the prior round');
|
|
1284
|
+
}
|
|
1285
|
+
if (prior?.verdict !== undefined && !['PASS', 'BOOTSTRAP', 'WIP', 'DOCUMENT'].includes(prior.verdict) && !args.revision) deny('failed review permits correction only');
|
|
1286
|
+
const chain = args.revision ? (prior?.chain ?? 0) + 1 : 1;
|
|
1287
|
+
if (chain > p.records.maxRounds || prior?.approval === 'Request revision' && !args.revision) deny('review cap reached or user requested revision; explicit user escalation required');
|
|
1288
|
+
this.ensureTaskLedger(p);
|
|
1289
|
+
const sceneExists = fs.existsSync(path.join(this.root, p.scene));
|
|
1290
|
+
p.change = {
|
|
1291
|
+
id: crypto.randomUUID(), created: Date.now(), outcome: args.outcome, milestone: milestone.trim(),
|
|
1292
|
+
requirements: args.requirements ? [...new Set(args.requirements.map((item) => item.trim()))] : [],
|
|
1293
|
+
scenes: args.scenes ? [...new Set(args.scenes)] : [], primaryScene: args.scenes?.[0] ?? null,
|
|
1294
|
+
sceneRequirements: args.sceneRequirements ? structuredClone(args.sceneRequirements) : {},
|
|
1295
|
+
renders: {}, inspections: {},
|
|
1296
|
+
final: args.final === true, objects: [...new Set(args.objects)], paths, base: this.head(), chain, operations: 0,
|
|
1297
|
+
deadline: Date.now() + CHANGE_WINDOW_MS, sealed: false, inspected: false, readyToSeal: false,
|
|
1298
|
+
wipRender: null, wipLook: null, commits: [], graders: {}, reports: {}, bootstrap: !sceneExists,
|
|
1299
|
+
documentOnly: paths.every(documentOnlyPath), nonSceneOnly: paths.every(nonScenePath), sceneHash: sceneExists ? this.hash(p.scene) : null,
|
|
1300
|
+
};
|
|
1301
|
+
} else if (action === 'extend') {
|
|
1302
|
+
if (!c || c.sealed || c.pending || c.documentOnly || c.readyToSeal) deny('extend requires an unsealed, unfinished scene change with no pending operation');
|
|
1303
|
+
if (args.objects !== undefined && (!Array.isArray(args.objects) || args.objects.length > 200 || args.objects.some((name) => typeof name !== 'string' || !/^[\x20-\x7e]{1,100}$/.test(name)))) deny('extend objects must be exact printable names');
|
|
1304
|
+
if (!Array.isArray(args.paths) || !args.paths.length || args.paths.length > 100) deny('extend requires exact project-relative paths');
|
|
1305
|
+
const added = [...new Set(args.paths.map((name) => {
|
|
1306
|
+
const full = contained(this.projectPath(p.project), name, true);
|
|
1307
|
+
const clean = path.relative(this.projectPath(p.project), full).replaceAll('\\', '/');
|
|
1308
|
+
if (/\.(?:py|js|mjs|ts|cmd|ps1|exe|dll|blend\d*)$/i.test(clean) && !/^scripts\/.*\.(?:py|pyw)$/i.test(clean) && `${p.project}/${clean}` !== p.scene) deny('production scope cannot add executable files except source-only scripts');
|
|
1309
|
+
return `${p.project}/${clean}`;
|
|
1310
|
+
}))];
|
|
1311
|
+
c.objects = [...new Set([...c.objects, ...(args.objects ?? [])])];
|
|
1312
|
+
if (c.objects.length > MAX_CHANGE_OBJECTS) deny(`changes are capped at ${MAX_CHANGE_OBJECTS} objects; split the milestone`);
|
|
1313
|
+
c.paths = [...new Set([...c.paths, ...added])];
|
|
1314
|
+
if (args.scenes !== undefined) {
|
|
1315
|
+
if (!Array.isArray(args.scenes) || args.scenes.length > MAX_CHANGE_SCENES || args.scenes.some((name) => !validSceneName(name))) deny(`scenes must be up to ${MAX_CHANGE_SCENES} printable scene names`);
|
|
1316
|
+
c.scenes = [...new Set([...(c.scenes ?? []), ...args.scenes])];
|
|
1317
|
+
if (c.scenes.length > MAX_CHANGE_SCENES) deny(`changes are capped at ${MAX_CHANGE_SCENES} scenes; split the milestone`);
|
|
1318
|
+
c.primaryScene ??= c.scenes[0];
|
|
1319
|
+
}
|
|
1320
|
+
if (args.sceneRequirements !== undefined) {
|
|
1321
|
+
if (!isPlainObject(args.sceneRequirements)) deny('sceneRequirements must be an object');
|
|
1322
|
+
for (const [requirement, scenes] of Object.entries(args.sceneRequirements)) {
|
|
1323
|
+
if (!c.requirements?.includes(requirement)) deny(`sceneRequirements key is not a declared requirement: ${requirement}`);
|
|
1324
|
+
if (!Array.isArray(scenes) || !scenes.length || scenes.some((name) => !validSceneName(name))) deny(`sceneRequirements.${requirement} must list printable scene names`);
|
|
1325
|
+
}
|
|
1326
|
+
c.sceneRequirements = { ...(c.sceneRequirements ?? {}), ...structuredClone(args.sceneRequirements) };
|
|
1327
|
+
}
|
|
1328
|
+
} else if (action === 'finalize') {
|
|
1329
|
+
if (!c || c.sealed || c.pending || c.documentOnly) deny('finalize requires an unsealed scene change with no pending operation');
|
|
1330
|
+
if (!c.operations) deny('finalize requires at least one successful apply');
|
|
1331
|
+
c.readyToSeal = true;
|
|
1332
|
+
} else if (action === 'document') {
|
|
1333
|
+
if (!c || c.pending) deny('open change with no pending operation required');
|
|
1334
|
+
if (typeof args.path !== 'string' || !args.path.trim() || typeof args.content !== 'string' || args.content.length > 1_000_000) deny('document path and bounded markdown content required');
|
|
1335
|
+
const full = contained(this.projectPath(p.project), args.path, true);
|
|
1336
|
+
const clean = path.relative(this.projectPath(p.project), full).replaceAll('\\', '/');
|
|
1337
|
+
if (!/\.md$/i.test(clean) || clean.startsWith('Documents/Reviews/')) deny('document action writes project markdown outside runtime review artifacts only');
|
|
1338
|
+
if (!c.paths.includes(`${p.project}/${clean}`)) deny('document path must be declared in the open change paths');
|
|
1339
|
+
if (Object.hasOwn(p.records.specs, `${p.project}/${clean}`) || `${p.project}/${clean}` === `${p.project}/Documents/REVIEW.md`) deny('registration documents are frozen after registration; restore the committed version and stop for explicit re-registration');
|
|
1340
|
+
if (clean === TASKSTRACKER_DOCUMENT || clean.startsWith('Documents/Tasks/')) deny('the task tracker is harness-owned; it updates on ticket issue and completion');
|
|
1341
|
+
try { validateDocument(clean, args.content); } catch (error) { deny(error.message); }
|
|
1342
|
+
fs.mkdirSync(path.dirname(full), { recursive: true });
|
|
1343
|
+
fs.writeFileSync(full, args.content);
|
|
1344
|
+
} else if (action === 'self-review') {
|
|
1345
|
+
if (!c?.inspected || p.records.review !== 'Off' || !args.notes?.trim()) deny('inspect sealed render before self-review');
|
|
1346
|
+
c.verdict = 'PASS'; c.notes = args.notes;
|
|
1347
|
+
const name = `${p.project}/Documents/Reviews/${c.round}/REVIEW.md`;
|
|
1348
|
+
const text = `# Orchestrator Self-review\n\nVerdict: PASS\nScene SHA-256: ${c.sceneHash}\nScenes: ${(c.scenes ?? []).join(', ') || 'active'}\nEvidence key: ${c.evidenceKey}\n\n${args.notes}\n`;
|
|
1349
|
+
fs.writeFileSync(contained(this.root, name, true), text, { flag: 'wx' });
|
|
1350
|
+
c.reports[name] = digest(text);
|
|
1351
|
+
this.updateManifest(p);
|
|
1352
|
+
} else if (action === 'grader-ticket') {
|
|
1353
|
+
if (!c?.inspected || p.records.review === 'Off') deny('sealed inspection required before grader dispatch');
|
|
1354
|
+
const g = p.records.roster.find((g) => g.name === args.name);
|
|
1355
|
+
if (!g) deny('unknown grader');
|
|
1356
|
+
const toolingBlocked = c.verdict === 'BLOCKED' && c.blockingKind === 'tooling';
|
|
1357
|
+
if (c.verdict && !toolingBlocked) deny('review already aggregated; begin a revision before re-dispatching graders');
|
|
1358
|
+
if (toolingBlocked) { c.verdict = null; c.blockingKind = null; }
|
|
1359
|
+
const existing = c.graders[g.name];
|
|
1360
|
+
const attempt = existing ? (existing.attempt ?? 1) : 0;
|
|
1361
|
+
const retryable = existing && (existing.error || existing.kind === 'tooling' || existing.verdict === 'BLOCKED' || (!existing.verdict && Date.now() > existing.deadline));
|
|
1362
|
+
if (existing && !retryable) deny('grader already dispatched');
|
|
1363
|
+
if (retryable && attempt >= 2) deny('grader retry budget exhausted; aggregate the round as blocked');
|
|
1364
|
+
if (Object.values(c.graders).filter((g) => g.child && !g.verdict && !g.error && Date.now() <= g.deadline).length >= p.records.maxParallel) deny('grader parallel limit reached');
|
|
1365
|
+
const token = crypto.randomUUID();
|
|
1366
|
+
const report = existing?.report ?? `${p.project}/Documents/Reviews/${c.round}/${g.name}/REPORT.md`;
|
|
1367
|
+
if (c.reports) delete c.reports[report];
|
|
1368
|
+
fs.mkdirSync(path.dirname(contained(this.root, report, true)), { recursive: true });
|
|
1369
|
+
const full = contained(this.root, report, true);
|
|
1370
|
+
if (fs.existsSync(full)) fs.rmSync(full);
|
|
1371
|
+
const deadline = Date.now() + g.max_minutes * 60000;
|
|
1372
|
+
c.graders[g.name] = { token, report, attempt: attempt + 1, deadline, steps: 0 };
|
|
1373
|
+
const task = this.recordGraderTask(p, token, g.name, report, deadline);
|
|
1374
|
+
this.updateManifest(p);
|
|
1375
|
+
const absolute = (name) => path.join(this.root, ...name.split('/'));
|
|
1376
|
+
const evidenceFiles = Object.keys(c.evidence ?? {});
|
|
1377
|
+
for (const name of evidenceFiles) {
|
|
1378
|
+
if (!fs.existsSync(absolute(name)) || this.hash(name) !== c.evidence[name]) deny(`frozen evidence missing or changed: ${name}`);
|
|
1379
|
+
}
|
|
1380
|
+
const evidenceLines = evidenceFiles.map((name) => `- ${absolute(name)} (sha256 ${c.evidence[name]})`);
|
|
1381
|
+
const manifestPath = absolute(c.manifestPath);
|
|
1382
|
+
if (!fs.existsSync(manifestPath)) deny('frozen review manifest missing');
|
|
1383
|
+
const reportPath = absolute(report);
|
|
1384
|
+
const dirty = gitText(this.root, ['status', '--porcelain', '--', p.project]) ? 'dirty' : 'clean';
|
|
1385
|
+
const prompt = [
|
|
1386
|
+
`HARNESS_GRADER:${token}`,
|
|
1387
|
+
`Round: ${c.round}`,
|
|
1388
|
+
`Project root: ${this.root}`,
|
|
1389
|
+
`Grader name / focus: ${g.name} / ${g.focus}`,
|
|
1390
|
+
`Model / reasoning: primary ${g.provider} / ${g.model} / ${g.variant}${g.backup ? `; backup ${g.backup.provider} / ${g.backup.model} / ${g.backup.variant}` : '; no backup configured'}`,
|
|
1391
|
+
`Milestone: ${c.milestone ?? c.outcome}`,
|
|
1392
|
+
`Requirements to grade: ${c.requirements?.length ? c.requirements.join(', ') : 'all requirements in the spec'}`,
|
|
1393
|
+
`Scenes to grade: ${(c.scenes ?? []).length ? c.scenes.join(', ') : 'active scene'}`,
|
|
1394
|
+
`Primary scene: ${c.primaryScene ?? 'active'}`,
|
|
1395
|
+
`Scene requirements: ${Object.keys(c.sceneRequirements ?? {}).length ? JSON.stringify(c.sceneRequirements) : 'none'}`,
|
|
1396
|
+
`Final full-spec review: ${c.final ? 'yes' : 'no'}`,
|
|
1397
|
+
`Review scope: ${c.final
|
|
1398
|
+
? 'Final full-spec review. Grade the complete deliverable and every assigned requirement.'
|
|
1399
|
+
: 'Partial milestone review. Grade only this milestone and the requirements it advances. Treat future or unbuilt scope as deferred, not a finding; do not report absent future content as blocker or major.'}`,
|
|
1400
|
+
`Spec: ${Object.keys(p.records.specs).map(absolute).join(', ')}`,
|
|
1401
|
+
`Target: base ${c.base}, saved scene SHA-256 ${c.sceneHash}, project ${dirty}`,
|
|
1402
|
+
`Evidence key: ${c.evidenceKey}`,
|
|
1403
|
+
'Evidence files (read these exact paths):',
|
|
1404
|
+
...evidenceLines,
|
|
1405
|
+
`Manifest: ${manifestPath}`,
|
|
1406
|
+
`Report path: ${reportPath}`,
|
|
1407
|
+
`Prior round: ${p.history.at(-1)?.round ?? 'none'}`,
|
|
1408
|
+
'Live inspection: not allowed',
|
|
1409
|
+
'User prompt: <verbatim user words for permitted live Blender calls>',
|
|
1410
|
+
'Prior findings / dispositions: <orchestrator-supplied re-review context or none>',
|
|
1411
|
+
`Budget: ${g.max_minutes} minutes, ${g.max_steps} steps`,
|
|
1412
|
+
'Report timing: read the required evidence and spec documents, then write the assigned REPORT.md immediately; do not defer the write until the end.',
|
|
1413
|
+
`Deadline UTC / rounds already used: ${new Date(deadline).toISOString()}`,
|
|
1414
|
+
'Stop: missing evidence/spec, out-of-folder path, scene edit request, budget reached',
|
|
1415
|
+
'Output: verdict, findings, <= 20 lines',
|
|
1416
|
+
].join('\n');
|
|
1417
|
+
return { token, marker: `HARNESS_GRADER:${token}`, grader: g, model: modelSummary(g), report, reportPath, evidenceFiles: evidenceLines, prompt, taskId: task.id,
|
|
1418
|
+
sceneHash: c.sceneHash, evidenceKey: c.evidenceKey, evidence: c.evidence, round: c.round, goal: `${p.project}/${p.goal}` };
|
|
1419
|
+
} else if (action === 'grader-fail') {
|
|
1420
|
+
if (Object.keys(args).some((key) => !['name', 'reason'].includes(key))) deny('grader-fail accepts name and reason only');
|
|
1421
|
+
if (!c?.sealed || c.verdict || p.records.review === 'Off') deny('grader failure requires an unaggregated sealed review');
|
|
1422
|
+
if (typeof args.name !== 'string' || typeof args.reason !== 'string' || !args.reason.trim() || args.reason.length > 500) deny('grader-fail requires name and one bounded reason');
|
|
1423
|
+
const g = p.records.roster.find((item) => item.name === args.name);
|
|
1424
|
+
if (!g) deny('unknown grader');
|
|
1425
|
+
const assignment = c.graders[g.name];
|
|
1426
|
+
if (!assignment) deny('grader has not been dispatched');
|
|
1427
|
+
if (assignment.verdict) deny('grader already has a verdict');
|
|
1428
|
+
assignment.verdict = 'BLOCKED';
|
|
1429
|
+
assignment.error = args.reason.trim();
|
|
1430
|
+
assignment.kind = 'tooling';
|
|
1431
|
+
const task = p.tasks?.find((item) => item.token === assignment.token);
|
|
1432
|
+
if (task) this.recordTaskFinished(p, assignment.token, { ok: false, error: assignment.error });
|
|
1433
|
+
this.updateManifest(p);
|
|
1434
|
+
if (task) this.commitTaskArtifacts(p, c, `task: ${task.id} ${task.state}`, { manifest: false });
|
|
1435
|
+
return this.status(p, s);
|
|
1436
|
+
} else if (action === 'review') {
|
|
1437
|
+
if (!c?.inspected || p.records.review === 'Off' || c.verdict) deny('sealed inspection required once before review');
|
|
1438
|
+
if (!args.notes?.trim() || !args.reason?.trim() || !args.dispositions?.trim()) deny('review requires notes, correction/no-correction reasoning, and finding dispositions');
|
|
1439
|
+
for (const roster of p.records.roster) {
|
|
1440
|
+
const g = c.graders[roster.name];
|
|
1441
|
+
if (g && !g.verdict && (g.error || Date.now() > g.deadline || ((g.attempt ?? 0) >= 2 && !g.child))) {
|
|
1442
|
+
g.verdict = 'BLOCKED';
|
|
1443
|
+
g.error ??= `grader deadline exceeded or retries exhausted before collection: ${roster.name}`;
|
|
1444
|
+
const task = p.tasks?.find((item) => item.token === g.token);
|
|
1445
|
+
if (task) this.recordTaskFinished(p, g.token, { ok: false, error: g.error });
|
|
1446
|
+
}
|
|
1447
|
+
}
|
|
1448
|
+
if (p.records.roster.some((g) => !c.graders[g.name]?.verdict)) deny('required grader reports incomplete');
|
|
1449
|
+
const verdicts = Object.values(c.graders).map((g) => g.verdict);
|
|
1450
|
+
c.verdict = verdicts.every((v) => v === 'PASS') ? 'PASS' : verdicts.includes('BLOCKED') ? 'BLOCKED' : 'CHANGES_REQUIRED';
|
|
1451
|
+
c.blockingKind = c.verdict === 'BLOCKED' ? 'tooling' : null;
|
|
1452
|
+
const name = `${p.project}/Documents/Reviews/${c.round}/REVIEW.md`;
|
|
1453
|
+
const text = [
|
|
1454
|
+
'# Review aggregate',
|
|
1455
|
+
`Verdict: ${c.verdict}`,
|
|
1456
|
+
`Scene SHA-256: ${c.sceneHash}`,
|
|
1457
|
+
`Scenes: ${(c.scenes ?? []).join(', ') || 'active'}`,
|
|
1458
|
+
`Evidence key: ${c.evidenceKey}`,
|
|
1459
|
+
'',
|
|
1460
|
+
'## Inspection notes',
|
|
1461
|
+
args.notes.trim(),
|
|
1462
|
+
'',
|
|
1463
|
+
'## Reason',
|
|
1464
|
+
args.reason.trim(),
|
|
1465
|
+
'',
|
|
1466
|
+
'## Finding dispositions',
|
|
1467
|
+
args.dispositions.trim(),
|
|
1468
|
+
'',
|
|
1469
|
+
'## Grader verdicts',
|
|
1470
|
+
...Object.entries(c.graders).map(([grader, g]) => `- ${grader}: ${g.verdict}${g.error ? ` (${g.error})` : ''}`),
|
|
1471
|
+
'',
|
|
1472
|
+
].join('\n');
|
|
1473
|
+
fs.writeFileSync(contained(this.root, name, true), text);
|
|
1474
|
+
c.reports[name] = digest(text); c.notes = args.notes; c.reason = args.reason; c.dispositions = args.dispositions;
|
|
1475
|
+
this.updateReviewIndex(p, c);
|
|
1476
|
+
this.updateManifest(p);
|
|
1477
|
+
if (p.records.workflow === 'Fully Autonomous' && c.verdict !== 'BLOCKED') this.autoCommit(p, c, `review: ${c.round} ${c.verdict}`);
|
|
1478
|
+
} else if (action === 'commit') {
|
|
1479
|
+
if (!c || c.pending) deny('open change with no pending operation required');
|
|
1480
|
+
if (typeof args.message !== 'string' || !args.message.trim() || args.message.length > 200 || /[\r\n]/.test(args.message)) deny('one bounded commit message required');
|
|
1481
|
+
if (args.mode !== undefined && args.mode !== 'wip') deny('commit mode must be wip when provided');
|
|
1482
|
+
const bootstrap = Boolean(c.bootstrap && c.saved && !c.sealed && !c.operations);
|
|
1483
|
+
const documentOnly = Boolean(c.nonSceneOnly && c.operations === 0 && this.hash(p.scene) === c.sceneHash);
|
|
1484
|
+
if (args.mode === 'wip') {
|
|
1485
|
+
if (!c.failure && Date.now() <= c.deadline) deny('WIP commit requires a failed or expired change');
|
|
1486
|
+
if (c.verdict) deny('reviewed changes use the normal commit path');
|
|
1487
|
+
this.autoCommit(p, c, args.message.trim(), { review: false });
|
|
1488
|
+
c.wip = true;
|
|
1489
|
+
c.verdict = 'WIP';
|
|
1490
|
+
} else {
|
|
1491
|
+
if (!bootstrap && !documentOnly && !c.verdict) deny('complete review before committing the change');
|
|
1492
|
+
this.autoCommit(p, c, args.message.trim(), { review: !bootstrap && !documentOnly });
|
|
1493
|
+
}
|
|
1494
|
+
} else if (action === 'preserve') {
|
|
1495
|
+
if (Object.keys(args).some((key) => key !== 'reason') || typeof args.reason !== 'string' || !args.reason.trim() || args.reason.length > 1000) deny('preserve requires one bounded reason');
|
|
1496
|
+
if (!c?.sealed || c.verdict !== 'BLOCKED' || c.blockingKind !== 'tooling') deny('preserve requires a sealed tooling-blocked change');
|
|
1497
|
+
if (c.preserved) deny('change is already preserved');
|
|
1498
|
+
c.preserved = { reason: args.reason.trim(), at: new Date().toISOString() };
|
|
1499
|
+
c.wip = true;
|
|
1500
|
+
c.verdict = 'WIP';
|
|
1501
|
+
c.blockingKind = null;
|
|
1502
|
+
this.updateManifest(p);
|
|
1503
|
+
this.autoCommit(p, c, `chore: preserve tooling-blocked ${c.round ?? 'review'}`);
|
|
1504
|
+
} else if (action === 'approval') {
|
|
1505
|
+
if (!c?.verdict || p.records.workflow !== 'Partially Autonomous') deny('complete review before approval');
|
|
1506
|
+
c.approvalQuestion ??= `${c.outcome} Review verdict: ${c.verdict}. Approve change or request revision? HARNESS CHANGE ${c.id} ${c.evidenceKey}`;
|
|
1507
|
+
return { question: c.approvalQuestion, labels: ['Approve change', 'Request revision', 'Pause'], outcome: c.outcome, verdict: c.verdict };
|
|
1508
|
+
} else if (action === 'checkpoint') {
|
|
1509
|
+
if (!c) deny('no open change to checkpoint');
|
|
1510
|
+
const bootstrap = Boolean(c.bootstrap && c.saved && !c.sealed && !c.operations);
|
|
1511
|
+
const wip = Boolean(c.wip);
|
|
1512
|
+
const documentOnly = Boolean(c.nonSceneOnly && c.operations === 0 && this.hash(p.scene) === c.sceneHash);
|
|
1513
|
+
const failedReview = c.verdict && !['PASS', 'WIP'].includes(c.verdict);
|
|
1514
|
+
if (c.verdict === 'BLOCKED' && c.blockingKind === 'tooling') deny('retry the tooling-blocked grader before checkpoint');
|
|
1515
|
+
const partialApprovalRequired = p.records.workflow === 'Partially Autonomous' && !documentOnly && (!c.approval || c.approval.evidence !== c.evidenceKey);
|
|
1516
|
+
if (!bootstrap && !wip && !documentOnly && (!c.verdict || !c.inspected || this.reviewPending(p, c) || c.pending || (failedReview && p.records.workflow === 'Partially Autonomous' && c.approval?.label !== 'Request revision') || partialApprovalRequired)) deny('review commit and runtime-captured approval required before checkpoint');
|
|
1517
|
+
if (this.head() === c.base) deny('commit missing; commit exact scoped files first');
|
|
1518
|
+
const changed = gitText(this.root, ['diff', '--name-only', `${c.base}..HEAD`]).split(/\r?\n/).filter(Boolean);
|
|
1519
|
+
const reviewed = bootstrap || documentOnly || (wip && !c.preserved) ? [] : this.reviewPaths(p, c);
|
|
1520
|
+
const allowed = new Set([...c.paths, p.scene, this.taskDocument(p), ...reviewed, ...this.taskFiles(p, c)]);
|
|
1521
|
+
const taskPrefix = `${p.project}/Documents/Tasks/`;
|
|
1522
|
+
const commandPrefix = `${p.project}/Documents/Commands/`;
|
|
1523
|
+
if (!changed.length || changed.some((name) => !allowed.has(name) && !name.startsWith(taskPrefix) && !name.startsWith(commandPrefix))) deny('commit contains files outside bounded change');
|
|
1524
|
+
for (const name of [p.scene, ...reviewed]) {
|
|
1525
|
+
if (!gitText(this.root, ['ls-files', '--error-unmatch', '--', name])) deny('required deliverable missing from checkpoint');
|
|
1526
|
+
}
|
|
1527
|
+
externalBinaryPolicy(this.root, p.project, git(this.root, ['ls-files', '-z', '--', p.project]).split('\0').filter(Boolean));
|
|
1528
|
+
const snapshot = this.snapshot(p.project); this.assertCommitted(p.project, snapshot);
|
|
1529
|
+
p.history.push({ id: c.id, round: c.round ?? null, verdict: bootstrap ? 'BOOTSTRAP' : wip ? 'WIP' : documentOnly ? 'DOCUMENT' : c.verdict, commit: this.head(), sceneHash: c.sceneHash, chain: c.chain, final: Boolean(c.final), approval: c.approval?.label ?? null });
|
|
1530
|
+
p.checkpoint = this.head(); p.snapshot = snapshot;
|
|
1531
|
+
if (!bootstrap && !wip && !documentOnly && c.verdict === 'PASS') {
|
|
1532
|
+
p.lastRender = { path: c.renderPath, hash: this.hash(c.renderPath) };
|
|
1533
|
+
p.lastRenders = Object.fromEntries(Object.entries(c.renders ?? {}).map(([name, render]) => [name, { path: render, hash: this.hash(render) }]));
|
|
1534
|
+
}
|
|
1535
|
+
p.change = null;
|
|
1536
|
+
} else deny(`unknown action: ${action}`);
|
|
1537
|
+
return this.status(p, s);
|
|
1538
|
+
});
|
|
1539
|
+
}
|
|
1540
|
+
|
|
1541
|
+
launch(session) {
|
|
1542
|
+
const p = this.transaction((s) => {
|
|
1543
|
+
const owner = this.owner(s, session);
|
|
1544
|
+
this.fresh(owner);
|
|
1545
|
+
if (owner.complete || !owner.goalActive) deny('launch requires the registered project with an active goal');
|
|
1546
|
+
if (!owner.change) this.assertCommitted(owner.project, this.snapshot(owner.project));
|
|
1547
|
+
return { project: owner.project, scene: owner.scene };
|
|
1548
|
+
});
|
|
1549
|
+
return launchProject({ root: this.root, home: this.home, project: p.project, scene: p.scene, session });
|
|
1550
|
+
}
|
|
1551
|
+
stop(session) {
|
|
1552
|
+
const p = this.transaction((s) => {
|
|
1553
|
+
const owner = this.owner(s, session);
|
|
1554
|
+
return { project: owner.project };
|
|
1555
|
+
});
|
|
1556
|
+
return stopProject({ root: this.root, home: this.home, project: p.project });
|
|
1557
|
+
}
|
|
1558
|
+
issue(session, operation, payload) {
|
|
1559
|
+
return this.transaction((s) => {
|
|
1560
|
+
const p = this.owner(s, session); this.fresh(p);
|
|
1561
|
+
const c = p.change;
|
|
1562
|
+
if (!c || c.sealed || c.pending || !p.goalActive || (operation === 'apply' && (c.failure || Date.now() > c.deadline || c.operations >= CHECKPOINT_EVERY))) deny('open bounded change required; save/seal remains available after budget or failure');
|
|
1563
|
+
if (operation === 'apply' && c.bootstrap) deny('bootstrap change accepts save only; save the scene, commit, checkpoint, then begin a scene change');
|
|
1564
|
+
if (operation === 'apply' && c.documentOnly) deny('document-only changes cannot apply scene operations; begin a scene change');
|
|
1565
|
+
if (operation === 'seal' && c.bootstrap) deny('bootstrap save cannot be sealed or graded; commit the saved scene and call checkpoint, then begin a scene change');
|
|
1566
|
+
if (operation === 'seal' && c.documentOnly) deny('document-only changes commit without sealing or review');
|
|
1567
|
+
const recovery = Boolean(c.failure || Date.now() > c.deadline);
|
|
1568
|
+
if (operation === 'wip' && (c.bootstrap || c.documentOnly || !c.operations || c.sealed)) deny('WIP render requires an unsealed scene change with at least one apply');
|
|
1569
|
+
if (operation === 'seal' && c.operations === 0 && !recovery) deny('seal requires at least one apply operation; document-only changes commit without sealing');
|
|
1570
|
+
if (operation === 'seal' && !recovery && !c.readyToSeal) deny('finalize the declared milestone before sealing');
|
|
1571
|
+
if (operation === 'seal' && !recovery && (!c.wipLook || c.wipLook.operations !== c.operations)) deny('seal requires a WIP render and blender_look receipt from the current apply state');
|
|
1572
|
+
const wipDue = c.operations > 0 && c.operations % 3 === 0 && (!c.wipRender || c.wipRender.operations !== c.operations);
|
|
1573
|
+
const wipLookDue = c.wipRender?.operations === c.operations && (!c.wipLook || c.wipLook.operations !== c.operations);
|
|
1574
|
+
if (operation === 'apply' && (wipDue || wipLookDue)) deny('apply blocked: WIP render and blender_look receipt required after every third apply; follow status.nextStep');
|
|
1575
|
+
if (!['apply', 'save', 'seal', 'wip'].includes(operation)) deny('unknown operation');
|
|
1576
|
+
const token = crypto.randomUUID();
|
|
1577
|
+
s.tickets[token] = { session, project: p.project, change: c.id, operation, payload, created: Date.now(), state: 'issued' };
|
|
1578
|
+
c.pending = token;
|
|
1579
|
+
const task = this.recordTaskIssued(p, token, operation, payload, s.tickets[token].created + TICKET_TTL_MS);
|
|
1580
|
+
return { token, operation, payload, taskId: task.id };
|
|
1581
|
+
});
|
|
1582
|
+
}
|
|
1583
|
+
authorize(session, token, operation, payload) {
|
|
1584
|
+
return this.transaction((s) => {
|
|
1585
|
+
const t = s.tickets[token];
|
|
1586
|
+
if (!t || t.session !== session || t.state !== 'issued' || t.operation !== operation || stable(t.payload) !== stable(payload) || Date.now() - t.created > TICKET_TTL_MS) deny('missing, stale, forged or replayed operation ticket');
|
|
1587
|
+
t.state = 'authorized';
|
|
1588
|
+
});
|
|
1589
|
+
}
|
|
1590
|
+
consume(token, operation, payload) {
|
|
1591
|
+
return this.transaction((s) => {
|
|
1592
|
+
const t = s.tickets[token];
|
|
1593
|
+
if (!t || t.state !== 'authorized' || t.operation !== operation || stable(t.payload) !== stable(payload) || Date.now() - t.created > TICKET_TTL_MS) deny('Kilo hook authorization missing or stale');
|
|
1594
|
+
const p = s.projects[t.project]; this.fresh(p);
|
|
1595
|
+
if (p.change?.id !== t.change || p.change.pending !== token) deny('ticket change mismatch');
|
|
1596
|
+
if (operation === 'apply' && (Date.now() > p.change.deadline || p.change.operations >= CHECKPOINT_EVERY)) deny('apply budget exhausted; seal, review, commit and checkpoint before more edits');
|
|
1597
|
+
t.state = 'running'; t.pid = process.pid;
|
|
1598
|
+
return { project: p.project, scene: p.scene, goal: p.goal, previousRound: p.history.at(-1)?.round ?? null, change: structuredClone(p.change), records: p.records,
|
|
1599
|
+
operationTasks: (p.tasks ?? []).filter((task) => task.change === p.change.id).map(({ token: _token, ...task }) => task) };
|
|
1600
|
+
});
|
|
1601
|
+
}
|
|
1602
|
+
finish(token, result) {
|
|
1603
|
+
return this.transaction((s) => {
|
|
1604
|
+
const t = s.tickets[token];
|
|
1605
|
+
if (!t || t.state !== 'running') deny('operation receipt has no running ticket');
|
|
1606
|
+
const p = s.projects[t.project], c = p.change;
|
|
1607
|
+
t.state = result.ok ? 'done' : 'failed'; delete c.pending;
|
|
1608
|
+
if (!result.ok) {
|
|
1609
|
+
c.failure = result.completed ? `Failed after ${result.completed} completed operation(s): ${result.error}` : result.error;
|
|
1610
|
+
c.deadline = 0;
|
|
1611
|
+
if (result.completed) { c.saved = true; c.sceneHash = this.hash(p.scene); }
|
|
1612
|
+
if (result.round) c.failedRounds = [...new Set([...(c.failedRounds ?? []), result.round])];
|
|
1613
|
+
this.recordTaskFinished(p, token, result);
|
|
1614
|
+
const task = p.tasks?.find((item) => item.token === token);
|
|
1615
|
+
if (task && !result.completed) this.commitTaskArtifacts(p, c, `task: ${task.id} ${task.state}`);
|
|
1616
|
+
return;
|
|
1617
|
+
}
|
|
1618
|
+
if (t.operation === 'apply') {
|
|
1619
|
+
delete c.failure; delete c.reopen; c.operations++; c.deadline = Date.now() + CHANGE_WINDOW_MS; c.saved = true; c.sceneHash = this.hash(p.scene);
|
|
1620
|
+
const touched = sceneNamesFromOperations(t.payload);
|
|
1621
|
+
const deleted = (Array.isArray(t.payload?.operations) ? t.payload.operations : [t.payload])
|
|
1622
|
+
.filter((operation) => operation?.operation === 'delete_scene' && validSceneName(operation.name))
|
|
1623
|
+
.map((operation) => operation.name);
|
|
1624
|
+
c.scenes = [...new Set([...(c.scenes ?? []), ...touched])].filter((name) => !deleted.includes(name));
|
|
1625
|
+
c.primaryScene ??= c.scenes[0] ?? null;
|
|
1626
|
+
}
|
|
1627
|
+
else if (t.operation === 'save') { c.saved = true; c.sceneHash = this.hash(p.scene); }
|
|
1628
|
+
else if (t.operation === 'wip') {
|
|
1629
|
+
c.saved = true; c.sceneHash = this.hash(p.scene);
|
|
1630
|
+
c.wipRender = { path: result.renderPath, hash: this.hash(result.renderPath), at: new Date().toISOString(), operations: c.operations };
|
|
1631
|
+
c.wipLook = null;
|
|
1632
|
+
delete c.failure; delete c.reopen; c.deadline = Date.now() + CHANGE_WINDOW_MS;
|
|
1633
|
+
} else {
|
|
1634
|
+
c.sealed = true; c.saved = true; c.sceneHash = this.hash(p.scene);
|
|
1635
|
+
delete c.failure; delete c.reopen; c.deadline = Date.now() + CHANGE_WINDOW_MS;
|
|
1636
|
+
c.round = result.round; c.renderPath = result.renderPath; c.evidence = result.evidence;
|
|
1637
|
+
c.renders = result.renders ?? (result.renderPath ? { [result.primaryScene ?? 'active']: result.renderPath } : {});
|
|
1638
|
+
c.scenes = result.scenes ?? c.scenes ?? [];
|
|
1639
|
+
c.primaryScene = result.primaryScene ?? c.scenes[0] ?? null;
|
|
1640
|
+
c.inspections = {};
|
|
1641
|
+
c.inspected = false;
|
|
1642
|
+
c.evidenceKey = digest(stable({ scene: c.sceneHash, specs: p.records.specs, evidence: c.evidence }));
|
|
1643
|
+
c.manifestPath = `${p.project}/Documents/Reviews/${c.round}/manifest.json`;
|
|
1644
|
+
c.manifestHash = this.hash(c.manifestPath);
|
|
1645
|
+
}
|
|
1646
|
+
if (t.operation === 'apply') {
|
|
1647
|
+
const operations = (result.operationResults ?? []).map((item) => item.operation);
|
|
1648
|
+
if (operations.some((name) => ASSET_USING_OPERATIONS.has(name))) p.assetUse = true;
|
|
1649
|
+
if (operations.includes('check_assets') && checkAssetsStrict(t.payload)) c.assetChecked = true;
|
|
1650
|
+
for (const item of result.operationResults ?? []) {
|
|
1651
|
+
if (item.operation === 'check_assets' && item.receipt?.strict === true) c.assetCheckReceipt = item.receipt;
|
|
1652
|
+
if (item.operation !== 'export_scene' || !item.receipt?.path) continue;
|
|
1653
|
+
p.exports = [...(p.exports ?? []).filter((receipt) => receipt.path !== item.receipt.path), { ...item.receipt, change: c.id, at: new Date().toISOString() }];
|
|
1654
|
+
}
|
|
1655
|
+
}
|
|
1656
|
+
if (t.operation === 'seal' && c.assetChecked) {
|
|
1657
|
+
p.assetCheck = { sceneHash: c.sceneHash, change: c.id, at: new Date().toISOString(), receipt: c.assetCheckReceipt ?? null };
|
|
1658
|
+
}
|
|
1659
|
+
this.recordTaskFinished(p, token, result);
|
|
1660
|
+
if (t.operation === 'apply') {
|
|
1661
|
+
const task = p.tasks?.find((item) => item.token === token);
|
|
1662
|
+
if (task) this.commitProgress(p, c, task);
|
|
1663
|
+
}
|
|
1664
|
+
if (t.operation === 'wip') {
|
|
1665
|
+
const task = p.tasks?.find((item) => item.token === token);
|
|
1666
|
+
if (task) this.commitTaskArtifacts(p, c, 'task: ' + task.id + ' ' + task.state);
|
|
1667
|
+
}
|
|
1668
|
+
if (t.operation === 'seal') {
|
|
1669
|
+
const task = p.tasks?.find((item) => item.token === token);
|
|
1670
|
+
if (task) this.commitTaskArtifacts(p, c, `task: ${task.id} ${task.state}`, { manifest: false });
|
|
1671
|
+
if (c.manifestPath) this.updateManifest(p);
|
|
1672
|
+
}
|
|
1673
|
+
return this.status(p, s);
|
|
1674
|
+
});
|
|
1675
|
+
}
|
|
1676
|
+
graderAssignment(state, child) {
|
|
1677
|
+
for (const p of Object.values(state.projects)) {
|
|
1678
|
+
for (const [name, g] of Object.entries(p.change?.graders ?? {})) if (g.child === child) return { p, name, g };
|
|
1679
|
+
}
|
|
1680
|
+
return null;
|
|
1681
|
+
}
|
|
1682
|
+
bindWorkerByToken(orchestrator, child, token) {
|
|
1683
|
+
return this.transaction((s) => {
|
|
1684
|
+
const p = this.owner(s, orchestrator);
|
|
1685
|
+
const task = p.tasks?.find((item) => item.token === token);
|
|
1686
|
+
if (!task || task.operation === 'save' || task.operation === 'seal' || task.operation === 'wip') return;
|
|
1687
|
+
if (task.child && task.child !== child) deny('worker token is already bound to another child');
|
|
1688
|
+
if (task.child === child && task.state === 'running') return;
|
|
1689
|
+
task.child = child;
|
|
1690
|
+
if (task.state === 'issued') task.state = 'running';
|
|
1691
|
+
this.writeTaskDocument(p, task);
|
|
1692
|
+
this.writeTaskLedger(p);
|
|
1693
|
+
});
|
|
1694
|
+
}
|
|
1695
|
+
bindWorker(orchestrator, child, prompt = '') {
|
|
1696
|
+
return this.transaction((s) => {
|
|
1697
|
+
const p = this.owner(s, orchestrator);
|
|
1698
|
+
const tokens = [...String(prompt).matchAll(/[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}/gi)].map((match) => match[0]);
|
|
1699
|
+
const task = p.tasks?.find((item) => tokens.includes(item.token)) ?? p.tasks?.find((item) => item.token === p.change?.pending);
|
|
1700
|
+
if (!task || task.operation === 'save' || task.operation === 'seal' || task.operation === 'wip') return;
|
|
1701
|
+
if (task.child && task.child !== child) deny('worker token is already bound to another child');
|
|
1702
|
+
if (task.child === child) return;
|
|
1703
|
+
task.child = child;
|
|
1704
|
+
if (task.state === 'issued') task.state = 'running';
|
|
1705
|
+
this.writeTaskDocument(p, task);
|
|
1706
|
+
this.writeTaskLedger(p);
|
|
1707
|
+
if (p.change && (task.state === 'done' || task.state === 'failed')) this.commitTaskArtifacts(p, p.change, `task: bind ${task.id} worker`);
|
|
1708
|
+
});
|
|
1709
|
+
}
|
|
1710
|
+
bindGrader(orchestrator, child, prompt, model) {
|
|
1711
|
+
return this.transaction((s) => {
|
|
1712
|
+
const p = this.owner(s, orchestrator); this.fresh(p);
|
|
1713
|
+
const c = p.change;
|
|
1714
|
+
if (!c?.inspected) deny('grader child requires inspected frozen evidence');
|
|
1715
|
+
const match = prompt.match(/HARNESS_GRADER:([a-f0-9-]{36})/);
|
|
1716
|
+
const entry = Object.entries(c.graders).find(([, g]) => g.token === match?.[1]);
|
|
1717
|
+
if (!entry || (entry[1].child && entry[1].child !== child)) deny('grader assignment missing or already owned');
|
|
1718
|
+
const cfg = p.records.roster.find((g) => g.name === entry[0]);
|
|
1719
|
+
const role = modelRole(cfg, model);
|
|
1720
|
+
if (!role) deny('grader runtime model does not match configured primary or backup');
|
|
1721
|
+
entry[1].child = child;
|
|
1722
|
+
entry[1].modelRole = role;
|
|
1723
|
+
const task = p.tasks?.find((item) => item.token === entry[1].token);
|
|
1724
|
+
if (task) {
|
|
1725
|
+
task.child = child;
|
|
1726
|
+
task.state = 'running';
|
|
1727
|
+
this.writeTaskDocument(p, task);
|
|
1728
|
+
this.writeTaskLedger(p);
|
|
1729
|
+
}
|
|
1730
|
+
this.updateManifest(p);
|
|
1731
|
+
});
|
|
1732
|
+
}
|
|
1733
|
+
failWorkerByToken(orchestrator, token, error) {
|
|
1734
|
+
return this.transaction((s) => {
|
|
1735
|
+
const p = this.owner(s, orchestrator);
|
|
1736
|
+
const task = p.tasks?.find((item) => item.token === token);
|
|
1737
|
+
if (!task || task.state === 'done' || task.state === 'failed') return;
|
|
1738
|
+
task.state = 'failed';
|
|
1739
|
+
task.finished = new Date().toISOString();
|
|
1740
|
+
task.result = String(error ?? 'worker task failed').slice(0, 300);
|
|
1741
|
+
this.writeTaskDocument(p, task);
|
|
1742
|
+
this.writeTaskSummary(p, task);
|
|
1743
|
+
this.writeTaskLedger(p);
|
|
1744
|
+
if (p.change) this.commitTaskArtifacts(p, p.change, `task: ${task.id} ${task.state}`);
|
|
1745
|
+
});
|
|
1746
|
+
}
|
|
1747
|
+
failGraderByToken(orchestrator, token, error) {
|
|
1748
|
+
return this.transaction((s) => {
|
|
1749
|
+
const p = this.owner(s, orchestrator); this.fresh(p);
|
|
1750
|
+
const entry = Object.entries(p.change?.graders ?? {}).find(([, g]) => g.token === token);
|
|
1751
|
+
if (!entry) return;
|
|
1752
|
+
const g = entry[1];
|
|
1753
|
+
if (g.verdict) return;
|
|
1754
|
+
g.verdict = 'BLOCKED'; g.error = String(error ?? 'grader task failed'); g.kind = 'tooling';
|
|
1755
|
+
const task = p.tasks?.find((item) => item.token === token);
|
|
1756
|
+
if (task) this.recordTaskFinished(p, token, { ok: false, error: g.error });
|
|
1757
|
+
this.updateManifest(p);
|
|
1758
|
+
if (task && p.change) this.commitTaskArtifacts(p, p.change, `task: ${task.id} ${task.state}`);
|
|
1759
|
+
});
|
|
1760
|
+
}
|
|
1761
|
+
graderStep(child, tool, args) {
|
|
1762
|
+
return this.transaction((s) => {
|
|
1763
|
+
const assignment = this.graderAssignment(s, child);
|
|
1764
|
+
if (!assignment) deny('grader session has no bound assignment');
|
|
1765
|
+
const { p, g, name } = assignment;
|
|
1766
|
+
this.fresh(p);
|
|
1767
|
+
const cfg = p.records.roster.find((v) => v.name === name);
|
|
1768
|
+
const reportWrite = tool === 'blender_document' && args?.action === 'write';
|
|
1769
|
+
if (g.verdict || (!reportWrite && (Date.now() > g.deadline || ++g.steps > cfg.max_steps))) deny('grader pass budget exhausted');
|
|
1770
|
+
this.updateManifest(p);
|
|
1771
|
+
return { project: p.project, report: g.report, sceneHash: p.change.sceneHash, evidenceKey: p.change.evidenceKey };
|
|
1772
|
+
});
|
|
1773
|
+
}
|
|
1774
|
+
collectGrader(orchestrator, child) {
|
|
1775
|
+
return this.transaction((s) => {
|
|
1776
|
+
const assignment = this.graderAssignment(s, child);
|
|
1777
|
+
if (!assignment || assignment.p.orchestrator !== orchestrator) deny('completed task has no assigned grader');
|
|
1778
|
+
const { p, g } = assignment; this.fresh(p);
|
|
1779
|
+
const fail = (error) => {
|
|
1780
|
+
g.verdict = 'BLOCKED'; g.error = error; g.kind = 'tooling';
|
|
1781
|
+
const task = p.tasks?.find((item) => item.token === g.token);
|
|
1782
|
+
if (task) this.recordTaskFinished(p, g.token, { ok: false, error });
|
|
1783
|
+
if (task) this.commitTaskArtifacts(p, p.change, `task: ${task.id} ${task.state}`, { manifest: false });
|
|
1784
|
+
this.updateManifest(p); return this.status(p, s);
|
|
1785
|
+
};
|
|
1786
|
+
const full = contained(this.root, g.report, true);
|
|
1787
|
+
if (!fs.existsSync(full)) return fail(`grader report missing: ${g.report}`);
|
|
1788
|
+
const text = fs.readFileSync(full, 'utf8');
|
|
1789
|
+
const verdicts = [...text.matchAll(/^- Verdict: (PASS|CHANGES_REQUIRED|BLOCKED)\s*$/gm)];
|
|
1790
|
+
if (verdicts.length !== 1 || !text.includes(p.change.sceneHash) || !text.includes(p.change.evidenceKey)) return fail(`grader report invalid: ${g.report}`);
|
|
1791
|
+
const missingScene = (p.change.scenes ?? []).length > 1 ? p.change.scenes.find((name) => !text.includes(name)) : null;
|
|
1792
|
+
if (missingScene) return fail(`grader report does not cite scene ${missingScene}: ${g.report}`);
|
|
1793
|
+
if (verdicts[0][1] === 'PASS' && /\|\s*(blocker|major)\s*\|/i.test(text)) return fail(`PASS report contains blocker/major finding: ${g.report}`);
|
|
1794
|
+
g.verdict = verdicts[0][1];
|
|
1795
|
+
g.kind = g.verdict === 'BLOCKED' ? 'tooling' : 'review';
|
|
1796
|
+
p.change.reports ??= {};
|
|
1797
|
+
delete p.change.reports[g.report];
|
|
1798
|
+
p.change.reports[g.report] = digest(text);
|
|
1799
|
+
const task = p.tasks?.find((item) => item.token === g.token);
|
|
1800
|
+
if (task) this.recordTaskFinished(p, g.token, { ok: g.verdict !== 'BLOCKED', error: g.verdict === 'BLOCKED' ? 'grader returned BLOCKED' : undefined });
|
|
1801
|
+
if (task) this.commitTaskArtifacts(p, p.change, `task: ${task.id} ${task.state}`, { manifest: false });
|
|
1802
|
+
this.updateManifest(p);
|
|
1803
|
+
return this.status(p, s);
|
|
1804
|
+
});
|
|
1805
|
+
}
|
|
1806
|
+
inspect(session, image, succeeded) {
|
|
1807
|
+
if (!succeeded) return;
|
|
1808
|
+
return this.transaction((s) => {
|
|
1809
|
+
const p = this.owner(s, session); this.fresh(p);
|
|
1810
|
+
const c = p.change;
|
|
1811
|
+
if (typeof image !== 'string') return;
|
|
1812
|
+
const candidates = [path.resolve(image), path.resolve(this.root, p.project, image)];
|
|
1813
|
+
const full = candidates.find((candidate) => candidate.startsWith(this.root + path.sep) && fs.existsSync(candidate));
|
|
1814
|
+
if (!full) return;
|
|
1815
|
+
const relative = path.relative(this.root, full).replaceAll('\\', '/');
|
|
1816
|
+
if (c.wipRender && relative === c.wipRender.path && c.wipRender.hash === this.hash(relative)) {
|
|
1817
|
+
c.wipLook = { path: relative, hash: this.hash(relative), at: new Date().toISOString(), operations: c.operations };
|
|
1818
|
+
return;
|
|
1819
|
+
}
|
|
1820
|
+
if (!c.sealed || c.evidence?.[relative] !== this.hash(relative)) return;
|
|
1821
|
+
const scene = Object.entries(c.renders ?? {}).find(([, render]) => render === relative)?.[0]
|
|
1822
|
+
?? (relative === c.renderPath ? c.primaryScene ?? 'active' : null);
|
|
1823
|
+
if (!scene) return;
|
|
1824
|
+
c.inspections ??= {};
|
|
1825
|
+
c.inspections[scene] = { path: relative, hash: this.hash(relative), at: new Date().toISOString() };
|
|
1826
|
+
c.inspected = sceneInspectionsComplete(c);
|
|
1827
|
+
});
|
|
1828
|
+
}
|
|
1829
|
+
|
|
1830
|
+
recover(session, args = {}) {
|
|
1831
|
+
return this.transaction((s) => {
|
|
1832
|
+
if (Object.keys(args).some((key) => key !== 'reopen') || (args.reopen !== undefined && args.reopen !== true)) deny('recover accepts reopen:true only');
|
|
1833
|
+
const p = this.owner(s, session);
|
|
1834
|
+
this.fresh(p);
|
|
1835
|
+
if (args.reopen) {
|
|
1836
|
+
const c = p.change;
|
|
1837
|
+
if (!c || c.sealed || c.pending || c.bootstrap) deny('reopen requires an unsealed reviewable change with no pending operation');
|
|
1838
|
+
if (c.operations >= CHECKPOINT_EVERY) deny('apply budget is exhausted; commit {mode:"wip"} and checkpoint before a fresh change');
|
|
1839
|
+
if (!c.failure && Date.now() <= c.deadline) deny('change has no failure or expired window to reopen');
|
|
1840
|
+
delete c.failure;
|
|
1841
|
+
c.reopen = true;
|
|
1842
|
+
c.deadline = Date.now() + CHANGE_WINDOW_MS;
|
|
1843
|
+
return this.status(p, s);
|
|
1844
|
+
}
|
|
1845
|
+
const token = p.change?.pending, t = s.tickets[token];
|
|
1846
|
+
if (!t || (t.state === 'running' && (!t.pid || (() => { try { process.kill(t.pid, 0); return true; } catch { return false; } })())) || t.state !== 'running' && Date.now() - t.created <= TICKET_TTL_MS) deny('no expired/dead operation; never release a live writer');
|
|
1847
|
+
const writerStarted = t.state === 'running';
|
|
1848
|
+
t.state = 'abandoned'; delete p.change.pending;
|
|
1849
|
+
const task = p.tasks?.find((item) => item.token === token);
|
|
1850
|
+
if (task) {
|
|
1851
|
+
task.state = 'abandoned';
|
|
1852
|
+
task.finished = new Date().toISOString();
|
|
1853
|
+
task.result = writerStarted ? 'abandoned after writer start' : 'expired before execution';
|
|
1854
|
+
this.writeTaskDocument(p, task);
|
|
1855
|
+
this.writeTaskSummary(p, task);
|
|
1856
|
+
this.writeTaskLedger(p);
|
|
1857
|
+
this.commitTaskArtifacts(p, p.change, `task: ${task.id} abandoned`);
|
|
1858
|
+
}
|
|
1859
|
+
if (writerStarted) {
|
|
1860
|
+
p.change.failure = 'Abandoned operation; inspect live scene, save and reseal before proceeding';
|
|
1861
|
+
p.change.deadline = 0;
|
|
1862
|
+
}
|
|
1863
|
+
return this.status(p, s);
|
|
1864
|
+
});
|
|
1865
|
+
}
|
|
1866
|
+
}
|