editorbrain 0.9.0 → 0.11.0
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/README.md +2 -2
- package/bin/editorbrain.mjs +574 -74
- package/package.json +1 -1
- package/skill/editorbrain/AGENTS.md +4 -0
- package/skill/editorbrain/SKILL.md +8 -2
- package/skill/editorbrain/references/cli-mcp.md +8 -0
- package/skill/editorbrain/references/memory-and-review.md +91 -0
- package/skill/editorbrain/references/style-creator.md +78 -0
- package/skill/editorbrain/references/workspace-files.md +9 -3
package/bin/editorbrain.mjs
CHANGED
|
@@ -1,5 +1,205 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
+
// packages/core/src/memory.ts
|
|
4
|
+
function memoryPath(scope, id) {
|
|
5
|
+
if (!["brand", "style", "project"].includes(scope) || !/^[a-z0-9][a-z0-9_-]{0,79}$/.test(id)) throw new Error("Invalid memory scope");
|
|
6
|
+
return scope === "project" ? `videos/${id}/preferences.json` : `.editorbrain/${scope === "brand" ? "brands" : "styles"}/${id}/preferences.json`;
|
|
7
|
+
}
|
|
8
|
+
function memoryScope(path) {
|
|
9
|
+
const m = path.match(/^\.editorbrain\/(brands|styles)\/([a-z0-9_-]+)\/preferences\.json$/), p = path.match(/^videos\/([a-z0-9_-]+)\/preferences\.json$/);
|
|
10
|
+
return m ? { scope: m[1] === "brands" ? "brand" : "style", scopeId: m[2] } : p ? { scope: "project", scopeId: p[1] } : null;
|
|
11
|
+
}
|
|
12
|
+
function memoryIssues(path, doc) {
|
|
13
|
+
const scope = memoryScope(path);
|
|
14
|
+
if (!scope) return [];
|
|
15
|
+
if (doc.scope !== scope.scope || doc.scopeId !== scope.scopeId || !Array.isArray(doc.entries) || doc.entries.length > 60) return ["Memory requires matching scope and at most 60 entries"];
|
|
16
|
+
const ids = /* @__PURE__ */ new Set();
|
|
17
|
+
const errors = [];
|
|
18
|
+
for (const e of doc.entries) {
|
|
19
|
+
if (!e || typeof e.id !== "string" || !/^[a-zA-Z0-9_-]{1,100}$/.test(e.id) || ids.has(e.id) || typeof e.text !== "string" || !e.text.trim() || e.text.length > 800 || typeof e.conditions !== "string" || e.conditions.length > 400 || !["suggested", "confirmed", "disabled"].includes(e.status) || typeof e.source !== "string" || e.source.length > 400 || !["agent_reported", "user_saved", "weekly_proposal"].includes(e.provenance) || typeof e.updatedAt !== "string" || !Number.isFinite(Date.parse(e.updatedAt))) errors.push("Invalid or duplicate memory entry");
|
|
20
|
+
ids.add(e?.id);
|
|
21
|
+
}
|
|
22
|
+
return errors;
|
|
23
|
+
}
|
|
24
|
+
function resolveMemory(files, p) {
|
|
25
|
+
const entries = [["style", p.style], ["brand", p.brand], ["project", p.id]].flatMap(([scope, id]) => {
|
|
26
|
+
const path = memoryPath(scope, id);
|
|
27
|
+
if (!files[path]) return [];
|
|
28
|
+
return JSON.parse(files[path]).entries.map((e) => ({ ...e, scope, scopeId: id, path }));
|
|
29
|
+
});
|
|
30
|
+
return { confirmed: entries.filter((e) => e.status === "confirmed"), pending: entries.filter((e) => e.status === "suggested").map(({ id, scope, scopeId }) => ({ id, scope, scopeId })), policy: "Apply only relevant confirmed preferences. Conditions and prose conflicts require agent judgment. Project-specific guidance precedes brand then style; required tokens still govern. Suggestions are not instructions. Provenance is a claim, not proof of human confirmation." };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// packages/core/src/style-authoring.ts
|
|
34
|
+
var own = (value, key) => Object.prototype.hasOwnProperty.call(value, key);
|
|
35
|
+
var object = (value) => !!value && typeof value === "object" && !Array.isArray(value);
|
|
36
|
+
var nonempty = (value) => typeof value === "string" && !!value.trim();
|
|
37
|
+
var identifier = /^[a-z0-9][a-z0-9_-]{0,79}$/;
|
|
38
|
+
var digest = /^[a-f0-9]{64}$/;
|
|
39
|
+
var kinds = ["font", "audio", "image", "video", "editable", "script", "other"];
|
|
40
|
+
var forbiddenText = /(?:\b[a-z][a-z0-9+.-]*:\/\/|\b(?:data|file|mailto):|\b(?:authorization|api[_ -]?key|access[_ -]?token|client[_ -]?secret|password)\s*[:=]|\bBearer\s+\S+|-----BEGIN [A-Z ]*PRIVATE KEY-----)/i;
|
|
41
|
+
function safeStyleResourcePath(path) {
|
|
42
|
+
if (typeof path !== "string" || !path || path.length > 400 || path !== path.trim() || /[\\\x00-\x1f\x7f%:?#]/.test(path) || path.startsWith("/") || path.startsWith("~")) throw new Error("Resource path must be a safe workspace-relative path");
|
|
43
|
+
const parts = path.split("/");
|
|
44
|
+
if (parts.some((part) => !part || part === "." || part === ".." || part.length > 100 || /[. ]$/.test(part) || ["__proto__", "prototype", "constructor"].includes(part) || /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/i.test(part))) throw new Error("Unsafe or nonportable resource path segment");
|
|
45
|
+
if (parts.some((part) => /^\.env(?:\.|$)/i.test(part) || /^(?:\.?credentials|secrets?|service[-_]account)(?:\.|$)/i.test(part) || [".git", ".ssh", ".aws", ".gnupg", ".sync.json", "id_rsa", "id_ed25519"].includes(part.toLowerCase()))) throw new Error("Credential and private configuration paths cannot be style resources");
|
|
46
|
+
return path;
|
|
47
|
+
}
|
|
48
|
+
function validateStyleResources(value, expectedId) {
|
|
49
|
+
const path = `.editorbrain/styles/${expectedId}/resources.json`;
|
|
50
|
+
const issues = [];
|
|
51
|
+
const add = (message) => issues.push({ path, message });
|
|
52
|
+
const fields = (entry, allowed, label) => {
|
|
53
|
+
if (Object.keys(entry).some((key) => !allowed.includes(key))) add(`${label} has unsupported fields; do not include URLs, credentials or hidden resource sources`);
|
|
54
|
+
};
|
|
55
|
+
const text = (value2, label) => {
|
|
56
|
+
if (!nonempty(value2)) add(`${label} must be nonempty text`);
|
|
57
|
+
else if (forbiddenText.test(value2) || /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/.test(value2)) add(`${label} cannot contain URLs, credentials or control characters`);
|
|
58
|
+
};
|
|
59
|
+
if (!object(value)) return { valid: false, issues: [{ path, message: "Style resources must be a JSON object" }] };
|
|
60
|
+
fields(value, ["schemaVersion", "id", "resources"], "Style resources");
|
|
61
|
+
if (value.schemaVersion !== 1) add("schemaVersion must be 1");
|
|
62
|
+
if (!identifier.test(expectedId) || value.id !== expectedId) add(`Style resource id must match path: ${expectedId}`);
|
|
63
|
+
if (!Array.isArray(value.resources)) add("Style resources requires a resources array");
|
|
64
|
+
else {
|
|
65
|
+
const ids = /* @__PURE__ */ new Set();
|
|
66
|
+
for (const [index, resource] of value.resources.entries()) {
|
|
67
|
+
const label = `Resource ${index + 1}`;
|
|
68
|
+
if (!object(resource)) {
|
|
69
|
+
add(`${label} must be an object`);
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
fields(resource, ["id", "kind", "purpose", "required", "source"], label);
|
|
73
|
+
if (typeof resource.id !== "string" || !identifier.test(resource.id) || ["constructor", "prototype"].includes(resource.id)) add(`${label} requires a valid identifier`);
|
|
74
|
+
else if (ids.has(resource.id)) add(`Duplicate resource id: ${resource.id}`);
|
|
75
|
+
else ids.add(resource.id);
|
|
76
|
+
if (!kinds.includes(resource.kind)) add(`${label} has an invalid kind`);
|
|
77
|
+
text(resource.purpose, `${label} purpose`);
|
|
78
|
+
if (typeof resource.required !== "boolean") add(`${label} required must be boolean`);
|
|
79
|
+
const source = resource.source;
|
|
80
|
+
if (!object(source)) {
|
|
81
|
+
add(`${label} source must be local, asset or requirement`);
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
if (source.type === "local") {
|
|
85
|
+
fields(source, ["type", "path", "sha256"], `${label} local source`);
|
|
86
|
+
try {
|
|
87
|
+
safeStyleResourcePath(source.path);
|
|
88
|
+
} catch (error) {
|
|
89
|
+
add(`${label}: ${error.message}`);
|
|
90
|
+
}
|
|
91
|
+
if (source.sha256 !== void 0 && (typeof source.sha256 !== "string" || !digest.test(source.sha256))) add(`${label} sha256 must be a 64-character lowercase hexadecimal digest`);
|
|
92
|
+
} else if (source.type === "asset") {
|
|
93
|
+
fields(source, ["type", "assetId"], `${label} asset source`);
|
|
94
|
+
if (typeof source.assetId !== "string" || !digest.test(source.assetId)) add(`${label} assetId must be a 64-character lowercase hexadecimal digest`);
|
|
95
|
+
} else if (source.type === "requirement") {
|
|
96
|
+
fields(source, ["type", "description"], `${label} requirement source`);
|
|
97
|
+
text(source.description, `${label} requirement description`);
|
|
98
|
+
} else add(`${label} source must be local, asset or requirement`);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return { valid: issues.length === 0, issues };
|
|
102
|
+
}
|
|
103
|
+
function createStyle(options) {
|
|
104
|
+
const id = assertIdentifier(options.id);
|
|
105
|
+
const base = `.editorbrain/styles/${id}`;
|
|
106
|
+
safeDocumentPath(`${base}/style.json`);
|
|
107
|
+
const name = options.name ?? id;
|
|
108
|
+
if (!nonempty(name)) throw new Error("Style name must be nonempty text");
|
|
109
|
+
const style = { schemaVersion: 1, id, name, tokens: {}, references: [], components: [{ id: "editorial-mechanism", status: "concept" }] };
|
|
110
|
+
const resources = { schemaVersion: 1, id, resources: [] };
|
|
111
|
+
return {
|
|
112
|
+
[`${base}/style.json`]: stableJson(style),
|
|
113
|
+
[`${base}/style.md`]: `# ${name}
|
|
114
|
+
|
|
115
|
+
STYLE_DRAFT
|
|
116
|
+
|
|
117
|
+
Este documento es un borrador. Sustituye las preguntas por decisiones y evidencia; los componentes comienzan como concept.
|
|
118
|
+
|
|
119
|
+
## Prop\xF3sito y encaje
|
|
120
|
+
|
|
121
|
+
Describe qu\xE9 funci\xF3n cumple el estilo, para qu\xE9 material y audiencia encaja, qu\xE9 necesita y cu\xE1ndo no conviene usarlo.
|
|
122
|
+
|
|
123
|
+
## Mecanismos que lo definen
|
|
124
|
+
|
|
125
|
+
Explica c\xF3mo interact\xFAan narrativa, composici\xF3n, tipograf\xEDa, ritmo y sonido. Define qu\xE9 debe conservarse para reconocer el estilo y qu\xE9 se adapta a la marca, el guion y el footage real. No heredes una paleta, fuente o identidad personal por defecto.
|
|
126
|
+
|
|
127
|
+
## T\xE9cnicas adaptables
|
|
128
|
+
|
|
129
|
+
Para cada t\xE9cnica registra su funci\xF3n, requisitos, ancla de timing (voz, acci\xF3n, lectura o beat), par\xE1metros adaptables, cu\xE1ndo omitirla y c\xF3mo verificarla en el render. Los timestamps del piloto son evidencia localizada, no tiempos fijos para otro video. Detalla movimiento, estados y cobertura cuando sean relevantes.
|
|
130
|
+
|
|
131
|
+
## Pilotos y evidencia
|
|
132
|
+
|
|
133
|
+
Registra pilotos propios o referencias autorizadas en examples.json y referencias expl\xEDcitas en style.json. Separa observaci\xF3n, propuesta e inferencia; distingue revisi\xF3n de agente de revisi\xF3n humana. Documenta qu\xE9 mecanismo se prob\xF3, el material de destino, el resultado y las limitaciones. Una declaraci\xF3n transfer_tested no demuestra la prueba.
|
|
134
|
+
|
|
135
|
+
## Recursos y l\xEDmites
|
|
136
|
+
|
|
137
|
+
Declara recursos en resources.json con su prop\xF3sito, si son necesarios y una ruta relativa al workspace, un assetId permanente o un requisito pendiente. Describe permisos y sustitutos. No guardes URLs temporales ni credenciales. Un scaffold o chequeo estructural no valida calidad editorial ni disponibilidad remota.
|
|
138
|
+
`,
|
|
139
|
+
[`${base}/examples.json`]: stableJson({ schemaVersion: 1, id, examples: [] }),
|
|
140
|
+
[`${base}/effects.md`]: "# Efectos y movimiento\n\nSTYLE_DRAFT\n\nPara cada t\xE9cnica define funci\xF3n, requisitos y evidencia del piloto; ancla entradas, estados, duraci\xF3n y salida a voz, acci\xF3n o tiempo de lectura del material real. Especifica qu\xE9 conservar, qu\xE9 adaptar, cu\xE1ndo omitir y c\xF3mo verificar movimiento, legibilidad y cobertura en el render. Separa lo observado de lo propuesto; no copies timestamps del piloto ni impongas efectos en cada beat. No deduzcas SFX a partir de im\xE1genes.\n",
|
|
141
|
+
[`${base}/music.md`]: "# M\xFAsica y sonido\n\nSTYLE_DRAFT\n\nDescribe funci\xF3n, energ\xEDa, instrumentaci\xF3n y uso del silencio seg\xFAn el relato. Ancla cambios a voz, acci\xF3n y beats reales; define cu\xE1ndo omitir m\xFAsica o SFX y c\xF3mo verificar inteligibilidad y mezcla escuchando el render. Separa sonidos corroborados de candidatos. Declara los recursos necesarios en resources.json y documenta su procedencia, licencia y sustitutos: una referencia no concede derechos de reutilizaci\xF3n.\n",
|
|
142
|
+
[`${base}/resources.json`]: stableJson(resources)
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
function checkStyle(files, id) {
|
|
146
|
+
const issues = [];
|
|
147
|
+
const warnings = [];
|
|
148
|
+
const manualChecks = [
|
|
149
|
+
"Review purpose, fit, defining mechanisms, what stays and what adapts against the actual destination material.",
|
|
150
|
+
"Inspect pilot evidence and its coverage; distinguish observations, proposals, agent review and human verification.",
|
|
151
|
+
"Preview the implemented edit: verify timing anchors, movement, legibility, audio and when each technique should be omitted.",
|
|
152
|
+
"Verify resource availability, local bytes, remote workspace authorization, provenance and reuse rights. Structural validation does not certify these."
|
|
153
|
+
];
|
|
154
|
+
const finish = (documents2) => ({ valid: issues.length === 0, issues, warnings, documents: documents2, manualChecks });
|
|
155
|
+
const base = `.editorbrain/styles/${id}/`;
|
|
156
|
+
try {
|
|
157
|
+
assertIdentifier(id);
|
|
158
|
+
safeDocumentPath(`${base}style.json`);
|
|
159
|
+
} catch (error) {
|
|
160
|
+
issues.push({ path: base, message: error.message });
|
|
161
|
+
return finish([]);
|
|
162
|
+
}
|
|
163
|
+
let selected;
|
|
164
|
+
try {
|
|
165
|
+
selected = canonicalFiles(Object.fromEntries(Object.entries(files).filter(([path]) => canonicalDocumentPath(path).startsWith(base))));
|
|
166
|
+
} catch (error) {
|
|
167
|
+
issues.push({ path: base, message: error.message });
|
|
168
|
+
return finish([]);
|
|
169
|
+
}
|
|
170
|
+
const documents = Object.keys(selected).sort();
|
|
171
|
+
for (const name of ["style.json", "style.md", "examples.json"]) {
|
|
172
|
+
if (!own(selected, base + name)) issues.push({ path: base + name, message: "Missing required style document" });
|
|
173
|
+
}
|
|
174
|
+
for (const name of ["effects.md", "music.md"]) if (!own(selected, base + name)) warnings.push(`${base + name}: Optional guide is absent; document the relevant treatment before using this style.`);
|
|
175
|
+
for (const path of documents) {
|
|
176
|
+
const content = selected[path];
|
|
177
|
+
const result = validateDocument(path, content);
|
|
178
|
+
issues.push(...result.issues);
|
|
179
|
+
if (typeof content !== "string") continue;
|
|
180
|
+
if (!content.trim()) warnings.push(`${path}: Document is empty.`);
|
|
181
|
+
if (/^\s*(?:STYLE_DRAFT|DRAFT|TODO(?::[^\n]*)?)\s*$/m.test(content)) warnings.push(`${path}: Draft markers remain; complete the authored guidance before claiming readiness.`);
|
|
182
|
+
if (!result.valid || !path.endsWith(".json")) continue;
|
|
183
|
+
const doc = JSON.parse(content);
|
|
184
|
+
if (path === base + "examples.json") {
|
|
185
|
+
if (!Array.isArray(doc.examples)) issues.push({ path, message: "Style examples requires an examples array" });
|
|
186
|
+
else if (!doc.examples.length) warnings.push(`${path}: No pilot examples are recorded; evidence and transfer have not been assessed.`);
|
|
187
|
+
else warnings.push(`${path}: Example entries are preserved as descriptive data; this check cannot assess their evidence, review claims or transfer quality.`);
|
|
188
|
+
}
|
|
189
|
+
if (path === base + "style.json" && Array.isArray(doc.components)) {
|
|
190
|
+
for (const component of doc.components) {
|
|
191
|
+
if (component.status === "concept") warnings.push(`${path}: Component ${component.id} remains concept; implementation is pending.`);
|
|
192
|
+
if (component.status === "transfer_tested") warnings.push(`${path}: Component ${component.id} declares transfer_tested; the declaration is not proof of a tested transfer.`);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
if (path === base + "resources.json") {
|
|
196
|
+
const resources = doc;
|
|
197
|
+
for (const resource of resources.resources) if (resource.source.type === "requirement") warnings.push(`${path}: Resource ${resource.id} is a requirement; availability has not been verified.`);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
return finish(documents);
|
|
201
|
+
}
|
|
202
|
+
|
|
3
203
|
// packages/core/src/index.ts
|
|
4
204
|
var SCHEMA_VERSION = 1;
|
|
5
205
|
var MAX_DOCUMENT_BYTES = 128 * 1024;
|
|
@@ -11,15 +211,15 @@ var ValidationError = class extends Error {
|
|
|
11
211
|
}
|
|
12
212
|
issues;
|
|
13
213
|
};
|
|
14
|
-
var
|
|
214
|
+
var identifier2 = /^[a-z0-9][a-z0-9_-]{0,79}$/;
|
|
15
215
|
var tokenName = /^[a-z][a-z0-9_.-]{0,79}$/;
|
|
16
|
-
var
|
|
17
|
-
var
|
|
18
|
-
var
|
|
216
|
+
var own2 = (value, key) => Object.prototype.hasOwnProperty.call(value, key);
|
|
217
|
+
var object2 = (value) => !!value && typeof value === "object" && !Array.isArray(value);
|
|
218
|
+
var nonempty2 = (value) => typeof value === "string" && value.trim().length > 0;
|
|
19
219
|
var finite = (value) => typeof value === "number" && Number.isFinite(value);
|
|
20
220
|
var tokenValue = (value) => typeof value === "string" || typeof value === "boolean" || finite(value) || Array.isArray(value) && value.every((v) => typeof v === "string");
|
|
21
221
|
function assertIdentifier(value) {
|
|
22
|
-
if (!
|
|
222
|
+
if (!identifier2.test(value)) throw new Error(`Invalid identifier: ${value}. Use lowercase letters, numbers, _ or - (max 80).`);
|
|
23
223
|
return value;
|
|
24
224
|
}
|
|
25
225
|
function safeDocumentPath(path) {
|
|
@@ -31,13 +231,13 @@ function safeDocumentPath(path) {
|
|
|
31
231
|
return path;
|
|
32
232
|
}
|
|
33
233
|
function checkRules(value, add) {
|
|
34
|
-
if (!
|
|
234
|
+
if (!object2(value)) {
|
|
35
235
|
add("tokens must be an object");
|
|
36
236
|
return;
|
|
37
237
|
}
|
|
38
238
|
for (const [key, rule] of Object.entries(value)) {
|
|
39
239
|
if (!tokenName.test(key) || ["__proto__", "constructor", "prototype"].includes(key)) add(`Invalid semantic token ${key}`);
|
|
40
|
-
if (!
|
|
240
|
+
if (!object2(rule) || !tokenValue(rule.value) || !["required", "preferred", "example"].includes(String(rule.strength))) add(`Token ${key} requires value and strength required/preferred/example`);
|
|
41
241
|
}
|
|
42
242
|
}
|
|
43
243
|
function checkEvidence(value, add) {
|
|
@@ -46,7 +246,7 @@ function checkEvidence(value, add) {
|
|
|
46
246
|
return;
|
|
47
247
|
}
|
|
48
248
|
for (const ref of value) {
|
|
49
|
-
if (!
|
|
249
|
+
if (!object2(ref) || !nonempty2(ref.videoId) || !["reference_only", "licensed_asset", "owned_asset"].includes(String(ref.relationship))) add("Evidence must include videoId and explicit relationship");
|
|
50
250
|
else {
|
|
51
251
|
if (ref.timestampSeconds !== void 0 && (!finite(ref.timestampSeconds) || ref.timestampSeconds < 0)) add("Evidence timestamp must be nonnegative seconds");
|
|
52
252
|
if (ref.reviewStatus !== void 0 && !["unreviewed", "agent_reviewed", "human_verified"].includes(String(ref.reviewStatus))) add("Invalid evidence reviewStatus");
|
|
@@ -75,11 +275,12 @@ function validateDocument(path, content) {
|
|
|
75
275
|
add("Invalid JSON");
|
|
76
276
|
return { valid: false, issues };
|
|
77
277
|
}
|
|
78
|
-
if (!
|
|
278
|
+
if (!object2(doc)) {
|
|
79
279
|
add("JSON document must be an object");
|
|
80
280
|
return { valid: false, issues };
|
|
81
281
|
}
|
|
82
282
|
if (doc.schemaVersion !== SCHEMA_VERSION) add("schemaVersion must be 1");
|
|
283
|
+
for (const message of memoryIssues(canonicalDocumentPath(path), doc)) add(message);
|
|
83
284
|
const parts = canonicalDocumentPath(path).split("/");
|
|
84
285
|
const isBrand = parts[0] === ".editorbrain" && parts[1] === "brands" && parts.length === 4;
|
|
85
286
|
const isStyle = parts[0] === ".editorbrain" && parts[1] === "styles" && parts.length === 4;
|
|
@@ -88,38 +289,39 @@ function validateDocument(path, content) {
|
|
|
88
289
|
const isMedia = parts[0] === "videos" && parts.length === 3 && parts[2] === "media.json";
|
|
89
290
|
if (isBrand || isStyle || isProject || isOutput) {
|
|
90
291
|
const expected = isOutput ? parts[2].slice(0, -5) : parts[isProject ? 1 : 2];
|
|
91
|
-
if (!
|
|
292
|
+
if (!identifier2.test(expected)) add("Invalid resource identifier in path");
|
|
92
293
|
if (doc.id !== void 0 && doc.id !== expected) add(`Document id must match path: ${expected}`);
|
|
93
294
|
}
|
|
295
|
+
if (isStyle && parts[3] === "resources.json") for (const issue of validateStyleResources(doc, parts[2]).issues) add(issue.message);
|
|
94
296
|
if (isBrand && parts[3] === "tokens.json" || isStyle && parts[3] === "style.json") {
|
|
95
|
-
if (!
|
|
297
|
+
if (!nonempty2(doc.id)) add("id is required");
|
|
96
298
|
if (isBrand || doc.tokens !== void 0) checkRules(doc.tokens, add);
|
|
97
299
|
if (isStyle) {
|
|
98
|
-
if (!
|
|
300
|
+
if (!nonempty2(doc.name)) add("Style name is required");
|
|
99
301
|
if (doc.references !== void 0) checkEvidence(doc.references, add);
|
|
100
|
-
if (doc.components !== void 0 && (!Array.isArray(doc.components) || doc.components.some((c) => !
|
|
302
|
+
if (doc.components !== void 0 && (!Array.isArray(doc.components) || doc.components.some((c) => !object2(c) || !nonempty2(c.id) || !["concept", "implemented", "transfer_tested"].includes(String(c.status))))) add("Components need id and explicit implementation status");
|
|
101
303
|
}
|
|
102
304
|
}
|
|
103
305
|
if (isProject) {
|
|
104
|
-
for (const key of ["id", "brand", "style", "outputProfile"]) if (typeof doc[key] !== "string" || !
|
|
105
|
-
if (!
|
|
306
|
+
for (const key of ["id", "brand", "style", "outputProfile"]) if (typeof doc[key] !== "string" || !identifier2.test(doc[key])) add(`${key} must be a valid identifier`);
|
|
307
|
+
if (!object2(doc.intent) || !nonempty2(doc.intent.topic) || !nonempty2(doc.intent.goal)) add("intent requires topic and goal");
|
|
106
308
|
if (doc.reviewMode !== void 0 && !["direct", "directions", "sample"].includes(doc.reviewMode)) add("reviewMode must be direct, directions or sample");
|
|
107
309
|
if (doc.script !== void 0 && typeof doc.script !== "string") add("script must be text");
|
|
108
310
|
if (doc.overrides !== void 0) {
|
|
109
|
-
if (!
|
|
311
|
+
if (!object2(doc.overrides)) add("overrides must be an object");
|
|
110
312
|
else for (const [key, rule] of Object.entries(doc.overrides)) {
|
|
111
313
|
if (!tokenName.test(key) || ["__proto__", "constructor", "prototype"].includes(key)) add(`Invalid override token ${key}`);
|
|
112
|
-
if (!
|
|
314
|
+
if (!object2(rule) || !tokenValue(rule.value) || !nonempty2(rule.reason) || rule.overrideRequired !== void 0 && typeof rule.overrideRequired !== "boolean") add(`Override ${key} requires value and a reason; overrideRequired must be boolean`);
|
|
113
315
|
}
|
|
114
316
|
}
|
|
115
317
|
}
|
|
116
318
|
if (isMedia) {
|
|
117
|
-
if (!
|
|
319
|
+
if (!identifier2.test(parts[1])) add("Invalid project identifier in media path");
|
|
118
320
|
if (!Array.isArray(doc.assets)) add("Production media requires an assets array");
|
|
119
321
|
else {
|
|
120
322
|
const ids = /* @__PURE__ */ new Set();
|
|
121
323
|
for (const asset of doc.assets) {
|
|
122
|
-
if (!
|
|
324
|
+
if (!object2(asset) || typeof asset.id !== "string" || !/^[a-f0-9]{64}$/.test(asset.id) || !nonempty2(asset.name) || typeof asset.mimeType !== "string" || !/^(video|image|audio)\/[a-zA-Z0-9][a-zA-Z0-9.+-]*$/.test(asset.mimeType) || !Number.isSafeInteger(asset.bytes) || Number(asset.bytes) < 1) {
|
|
123
325
|
add("Production assets require a SHA-256 id, name, media mimeType and positive integer bytes");
|
|
124
326
|
continue;
|
|
125
327
|
}
|
|
@@ -129,11 +331,11 @@ function validateDocument(path, content) {
|
|
|
129
331
|
}
|
|
130
332
|
}
|
|
131
333
|
if (isOutput) {
|
|
132
|
-
if (!
|
|
334
|
+
if (!nonempty2(doc.id)) add("id is required");
|
|
133
335
|
for (const key of ["width", "height"]) if (!Number.isInteger(doc[key]) || Number(doc[key]) < 1 || Number(doc[key]) > 16384) add(`${key} must be an integer between 1 and 16384`);
|
|
134
336
|
if (!finite(doc.fps) || doc.fps <= 0 || doc.fps > 240) add("fps must be between 0 and 240");
|
|
135
337
|
if (doc.safeAreas !== void 0) {
|
|
136
|
-
if (!
|
|
338
|
+
if (!object2(doc.safeAreas) || ["top", "right", "bottom", "left"].some((k) => !finite(doc.safeAreas[k]) || Number(doc.safeAreas[k]) < 0 || Number(doc.safeAreas[k]) >= 0.5)) add("safeAreas must have normalized fractions from 0 up to (not including) 0.5 for all four sides");
|
|
137
339
|
}
|
|
138
340
|
}
|
|
139
341
|
return { valid: issues.length === 0, issues };
|
|
@@ -154,7 +356,7 @@ function projectSources(project) {
|
|
|
154
356
|
];
|
|
155
357
|
}
|
|
156
358
|
function optionalProjectSources(project) {
|
|
157
|
-
return [`.editorbrain/brands/${project.brand}/design.md`, `.editorbrain/styles/${project.style}/effects.md`, `.editorbrain/styles/${project.style}/music.md`, `videos/${project.id}/media.json`];
|
|
359
|
+
return [`.editorbrain/brands/${project.brand}/design.md`, `.editorbrain/styles/${project.style}/effects.md`, `.editorbrain/styles/${project.style}/music.md`, `videos/${project.id}/media.json`, memoryPath("brand", project.brand), memoryPath("style", project.style), memoryPath("project", project.id), `.editorbrain/styles/${project.style}/resources.json`];
|
|
158
360
|
}
|
|
159
361
|
function validateWorkspace(files) {
|
|
160
362
|
files = canonicalFiles(files);
|
|
@@ -163,31 +365,31 @@ function validateWorkspace(files) {
|
|
|
163
365
|
for (const [path, content] of Object.entries(files)) {
|
|
164
366
|
if (!/^videos\/[^/]+\/project\.json$/.test(path) || !validateDocument(path, content).valid) continue;
|
|
165
367
|
const project = JSON.parse(content);
|
|
166
|
-
for (const required of projectSources(project)) if (!
|
|
368
|
+
for (const required of projectSources(project)) if (!own2(files, required)) issues.push({ path, message: `Missing required source ${required}` });
|
|
167
369
|
}
|
|
168
370
|
return { valid: issues.length === 0, issues };
|
|
169
371
|
}
|
|
170
372
|
function stableJson(value) {
|
|
171
|
-
const sort = (item) => Array.isArray(item) ? item.map(sort) :
|
|
373
|
+
const sort = (item) => Array.isArray(item) ? item.map(sort) : object2(item) ? Object.fromEntries(Object.keys(item).sort().map((key) => [key, sort(item[key])])) : item;
|
|
172
374
|
return JSON.stringify(sort(value), null, 2) + "\n";
|
|
173
375
|
}
|
|
174
376
|
async function sha256(content) {
|
|
175
377
|
const bytes = typeof content === "string" ? new TextEncoder().encode(content) : content;
|
|
176
|
-
const
|
|
177
|
-
return Array.from(new Uint8Array(
|
|
378
|
+
const digest2 = await crypto.subtle.digest("SHA-256", new Uint8Array(bytes));
|
|
379
|
+
return Array.from(new Uint8Array(digest2), (b) => b.toString(16).padStart(2, "0")).join("");
|
|
178
380
|
}
|
|
179
381
|
async function resolveProject(files, projectId) {
|
|
180
382
|
files = canonicalFiles(files);
|
|
181
383
|
assertIdentifier(projectId);
|
|
182
384
|
const path = `videos/${projectId}/project.json`;
|
|
183
|
-
if (!
|
|
385
|
+
if (!own2(files, path)) throw new ValidationError([{ path, message: "Project does not exist" }]);
|
|
184
386
|
const basic = validateDocument(path, files[path]);
|
|
185
387
|
if (!basic.valid) throw new ValidationError(basic.issues);
|
|
186
388
|
const project = JSON.parse(files[path]);
|
|
187
389
|
const requiredPaths = projectSources(project);
|
|
188
390
|
const optionalPaths = optionalProjectSources(project);
|
|
189
|
-
const sourcePaths = [...requiredPaths, ...optionalPaths.filter((p) =>
|
|
190
|
-
const relevant = Object.fromEntries(sourcePaths.filter((p) =>
|
|
391
|
+
const sourcePaths = [...requiredPaths, ...optionalPaths.filter((p) => own2(files, p))];
|
|
392
|
+
const relevant = Object.fromEntries(sourcePaths.filter((p) => own2(files, p)).map((p) => [p, files[p]]));
|
|
191
393
|
const validation = validateWorkspace(relevant);
|
|
192
394
|
if (!validation.valid) throw new ValidationError(validation.issues);
|
|
193
395
|
const [brandPath, tokensPath, languagePath, assetsPath, styleMdPath, stylePath, examplesPath, outputPath, , briefPath, directionPath] = requiredPaths;
|
|
@@ -228,11 +430,13 @@ async function resolveProject(files, projectId) {
|
|
|
228
430
|
output: output2,
|
|
229
431
|
tokens,
|
|
230
432
|
tokenSources,
|
|
231
|
-
editorial: { brand: files[brandPath], style: files[styleMdPath], brief: files[briefPath], direction: files[directionPath], ...Object.fromEntries(["design", "effects", "music"].flatMap((key, index) =>
|
|
433
|
+
editorial: { brand: files[brandPath], style: files[styleMdPath], brief: files[briefPath], direction: files[directionPath], ...Object.fromEntries(["design", "effects", "music"].flatMap((key, index) => own2(files, optionalPaths[index]) ? [[key, files[optionalPaths[index]]]] : [])) },
|
|
232
434
|
language: JSON.parse(files[languagePath]),
|
|
233
435
|
brandAssets: JSON.parse(files[assetsPath]),
|
|
234
436
|
examples: JSON.parse(files[examplesPath]),
|
|
235
|
-
...
|
|
437
|
+
...own2(files, optionalPaths[3]) ? { media: JSON.parse(files[optionalPaths[3]]) } : {},
|
|
438
|
+
...own2(files, optionalPaths[7]) ? { styleResources: JSON.parse(files[optionalPaths[7]]) } : {},
|
|
439
|
+
memory: resolveMemory(files, project),
|
|
236
440
|
evidence: style.references ?? [],
|
|
237
441
|
warnings,
|
|
238
442
|
lock: { schemaVersion: 1, projectId, sourceHashes, overrides: project.overrides ?? {}, adapters: Object.fromEntries(["neutral", "hyperframes", "remotion"].map((key) => [key, { version: "0.2.1", kind: "editorial_handoff", rendered: false }])) }
|
|
@@ -250,6 +454,10 @@ ${content}` : "";
|
|
|
250
454
|
|
|
251
455
|
\`\`\`json
|
|
252
456
|
${stableJson(resolved.media)}\`\`\``) : "";
|
|
457
|
+
const resourcesSection = resolved.styleResources ? section("Style resources", `Resolve local paths from the original workspace root; asset IDs require authorized workspace access. Availability and reuse rights must be verified before use.
|
|
458
|
+
|
|
459
|
+
\`\`\`json
|
|
460
|
+
${stableJson(resolved.styleResources)}\`\`\``) : "";
|
|
253
461
|
if (adapter === "neutral") return { ...common, "brief.md": `# ${resolved.project.id}
|
|
254
462
|
|
|
255
463
|
${disclaimer}
|
|
@@ -266,7 +474,7 @@ ${resolved.editorial.brand}
|
|
|
266
474
|
|
|
267
475
|
## Style
|
|
268
476
|
|
|
269
|
-
${resolved.editorial.style}${section("Design", resolved.editorial.design)}${section("Effects", resolved.editorial.effects)}${section("Music and sound", resolved.editorial.music)}${mediaSection}
|
|
477
|
+
${resolved.editorial.style}${section("Design", resolved.editorial.design)}${section("Effects", resolved.editorial.effects)}${section("Music and sound", resolved.editorial.music)}${mediaSection}${resourcesSection}
|
|
270
478
|
|
|
271
479
|
Exact tokens, output settings and evidence are in resolved.json.
|
|
272
480
|
` };
|
|
@@ -294,13 +502,13 @@ ${resolved.editorial.direction}
|
|
|
294
502
|
|
|
295
503
|
## Style intent
|
|
296
504
|
|
|
297
|
-
${resolved.editorial.style}${section("Effects", resolved.editorial.effects)}${section("Music and sound", resolved.editorial.music)}${mediaSection}
|
|
505
|
+
${resolved.editorial.style}${section("Effects", resolved.editorial.effects)}${section("Music and sound", resolved.editorial.music)}${mediaSection}${resourcesSection}
|
|
298
506
|
|
|
299
507
|
## Delivery
|
|
300
508
|
|
|
301
509
|
${resolved.output.width} \xD7 ${resolved.output.height}, ${resolved.output.fps} fps. Adapt composition to the actual material and safe areas in resolved.json. Implement and preview the composition in Hyperframes before claiming completion.
|
|
302
510
|
` };
|
|
303
|
-
if (adapter === "remotion") return { ...common, "remotion-plan.json": stableJson({ schemaVersion: 1, kind: "editorial_handoff", renderer: "remotion", executable: false, note: disclaimer, composition: { width: resolved.output.width, height: resolved.output.height, fps: resolved.output.fps, durationInFrames: null }, tokens: resolved.tokens, project: resolved.project, direction: resolved.editorial.direction, editorial: resolved.editorial, ...resolved.media ? { media: resolved.media } : {}, evidence: resolved.evidence, implementationTasks: ["Determine duration from the script/audio and actual cuts.", "Implement React composition and assets.", "Preview and verify typography, safe areas, motion and audio."] }) };
|
|
511
|
+
if (adapter === "remotion") return { ...common, "remotion-plan.json": stableJson({ schemaVersion: 1, kind: "editorial_handoff", renderer: "remotion", executable: false, note: disclaimer, composition: { width: resolved.output.width, height: resolved.output.height, fps: resolved.output.fps, durationInFrames: null }, tokens: resolved.tokens, project: resolved.project, direction: resolved.editorial.direction, editorial: resolved.editorial, ...resolved.media ? { media: resolved.media } : {}, ...resolved.styleResources ? { styleResources: resolved.styleResources } : {}, evidence: resolved.evidence, implementationTasks: ["Determine duration from the script/audio and actual cuts.", "Implement React composition and assets.", "Preview and verify typography, safe areas, motion and audio."] }) };
|
|
304
512
|
throw new Error(`Unsupported adapter: ${adapter}`);
|
|
305
513
|
}
|
|
306
514
|
function createWorkspace(options = {}) {
|
|
@@ -640,11 +848,11 @@ async function pushWorkspace(client, root, files, options = {}) {
|
|
|
640
848
|
for (const [path, content] of Object.entries(files)) {
|
|
641
849
|
const validation = validateDocument(path, content);
|
|
642
850
|
if (!validation.valid) throw new Error(validation.issues.map((x) => `${x.path}: ${x.message}`).join("\n"));
|
|
643
|
-
const
|
|
851
|
+
const digest2 = await sha256(content);
|
|
644
852
|
const existing = remote.get(path);
|
|
645
853
|
const base = state.documents[path];
|
|
646
|
-
if (existing?.sha256 ===
|
|
647
|
-
state.documents[path] = { revision: existing.revision, sha256:
|
|
854
|
+
if (existing?.sha256 === digest2) {
|
|
855
|
+
state.documents[path] = { revision: existing.revision, sha256: digest2 };
|
|
648
856
|
unchanged++;
|
|
649
857
|
continue;
|
|
650
858
|
}
|
|
@@ -665,8 +873,8 @@ ${conflicts.join("\n")}`, "Run push --dry-run and reconcile the reported paths."
|
|
|
665
873
|
for (const item of pending) {
|
|
666
874
|
const result = await client.put(item.path, item.content, item.expectedRevision);
|
|
667
875
|
checkRemote(result);
|
|
668
|
-
const
|
|
669
|
-
if (result.path !== item.path || result.sha256 !==
|
|
876
|
+
const digest2 = await sha256(item.content);
|
|
877
|
+
if (result.path !== item.path || result.sha256 !== digest2) throw new Error("Server returned inconsistent write metadata");
|
|
670
878
|
state.documents[item.path] = { revision: result.revision, sha256: result.sha256 };
|
|
671
879
|
uploaded.push(item.path);
|
|
672
880
|
await saveSyncState(root, state);
|
|
@@ -684,14 +892,14 @@ async function pullWorkspace(client, root, local, options = {}) {
|
|
|
684
892
|
for (const summary of summaries) {
|
|
685
893
|
checkRemote(summary);
|
|
686
894
|
const content = local[summary.path];
|
|
687
|
-
const
|
|
895
|
+
const digest2 = content === void 0 ? void 0 : await sha256(content);
|
|
688
896
|
const base = state.documents[summary.path];
|
|
689
|
-
if (
|
|
897
|
+
if (digest2 === summary.sha256) {
|
|
690
898
|
state.documents[summary.path] = { revision: summary.revision, sha256: summary.sha256 };
|
|
691
899
|
unchanged++;
|
|
692
900
|
continue;
|
|
693
901
|
}
|
|
694
|
-
if (content !== void 0 && (!base ||
|
|
902
|
+
if (content !== void 0 && (!base || digest2 !== base.sha256)) {
|
|
695
903
|
conflicts.push(summary.path);
|
|
696
904
|
continue;
|
|
697
905
|
}
|
|
@@ -777,7 +985,7 @@ import { createHash } from "node:crypto";
|
|
|
777
985
|
import { constants as constants2 } from "node:fs";
|
|
778
986
|
import { lstat as lstat2, open as open2, readdir as readdir2, rename as rename2 } from "node:fs/promises";
|
|
779
987
|
import { fileURLToPath } from "node:url";
|
|
780
|
-
var previousSkillHashes = { "SKILL.md": ["9df2c89deeec30ddf556c359027efe1d90e6b4e9c08717cd8827e210b38a187f", "67b157cb45cae3621e6f2c43235c1d69907ad6abd45ab6b52a3c65f236732db9", "1e2fff65a1b303b18b2d65156bef6eb2439a19d77ba2a25b37bcb1819ff009e3"], "AGENTS.md": ["4f3ef860ade3a31aa7c6a6aab5cf6ec2a700850bf19ea2320324c12c2e0fbf5b", "1b07de01698a0040b47ea0eddb1b017f70a69e81acd0bf9710647ca5e02f5125", "b5ee3439ff974c668755816149fab76899d7ad9266f63978b30fbc9b7d2cd640"], "references/cli-mcp.md": ["5d3a79343fbd76cdae5bdbe12fb9c944d5dff4f489ac6534fd96e0cb8630b988", "8e011c31368cef2117a28ee972b39aab9535eeb0f601a4a0f9d6fc019b22c1c2", "666082653efc000f8a7cc5f1836c80bbdda5f0f57cdbbdcb393f2b84aeb93a01", "78b737796a8685b1135e0acb91b620c51845b62e31dba04eb51dd9dd38b41008"], "references/production-and-review.md": ["055c6df9e0f167be148b0a5c0bba5b43e108eb1f58f478d88b3536c6589ac7b3"], "references/workspace-files.md": ["e72af2871f42cd776be1bfff8009ed5562780e7f8a65b12c27887f3880631f81", "00780c64bd0244c6afb18d786b7512b43e315c376e8f88c67d6ba53d91275ccf", "97053b092e0ff36e3cfb764095fcb4a4fca94e722567e774b53bc118279f40e5"] };
|
|
988
|
+
var previousSkillHashes = { "SKILL.md": ["9df2c89deeec30ddf556c359027efe1d90e6b4e9c08717cd8827e210b38a187f", "67b157cb45cae3621e6f2c43235c1d69907ad6abd45ab6b52a3c65f236732db9", "1e2fff65a1b303b18b2d65156bef6eb2439a19d77ba2a25b37bcb1819ff009e3", "dacce1c4127f4281df6f7e1681e243444bb6f8a8e74b829e52ee0108c6672348", "09740ab4b275eada65b5040ae6000104ff39094f534a2784e67feb2afc2a59b3"], "AGENTS.md": ["4f3ef860ade3a31aa7c6a6aab5cf6ec2a700850bf19ea2320324c12c2e0fbf5b", "1b07de01698a0040b47ea0eddb1b017f70a69e81acd0bf9710647ca5e02f5125", "b5ee3439ff974c668755816149fab76899d7ad9266f63978b30fbc9b7d2cd640", "bacedb7bdedab5abe75c9601717cfdb05a96208fa2e2a42af878e2dfd2ae4251", "ea3b473179857ccc07e9e4f21a6ede9e0906a09e495f4a051069d15aee1556cd"], "references/cli-mcp.md": ["5d3a79343fbd76cdae5bdbe12fb9c944d5dff4f489ac6534fd96e0cb8630b988", "8e011c31368cef2117a28ee972b39aab9535eeb0f601a4a0f9d6fc019b22c1c2", "666082653efc000f8a7cc5f1836c80bbdda5f0f57cdbbdcb393f2b84aeb93a01", "78b737796a8685b1135e0acb91b620c51845b62e31dba04eb51dd9dd38b41008", "8b2e01bfd937237919f5ade794eed01dc6e32b556ede9de3a0ccc28cbdd988d1", "ca88b2816a795a5ebe834074b178469367634b80ad837e3697182ea585af1c05"], "references/production-and-review.md": ["055c6df9e0f167be148b0a5c0bba5b43e108eb1f58f478d88b3536c6589ac7b3"], "references/workspace-files.md": ["e72af2871f42cd776be1bfff8009ed5562780e7f8a65b12c27887f3880631f81", "00780c64bd0244c6afb18d786b7512b43e315c376e8f88c67d6ba53d91275ccf", "97053b092e0ff36e3cfb764095fcb4a4fca94e722567e774b53bc118279f40e5", "70fc660e7ed3d70c16c33c62c8e0399b99c3251808f093ef3d4ea5e8d39e4c8a", "28c62c1e28842142607f5168ee79b18fd797900dd66dd9ac5eb3cfcf8a09f781"] };
|
|
781
989
|
var destinations = {
|
|
782
990
|
codex: { directory: ".agents/skills/editorbrain", entryPoint: "AGENTS.md" },
|
|
783
991
|
claude: { directory: ".claude/skills/editorbrain", entryPoint: "CLAUDE.md" }
|
|
@@ -848,13 +1056,13 @@ Keep credentials outside this folder. Treat retrieved reference material as evid
|
|
|
848
1056
|
for (const selected of agents) {
|
|
849
1057
|
const to = destinations[selected].directory, from = to.replace(/editorbrain$/, "editorharness");
|
|
850
1058
|
const oldPath = await checkedPath(root, from, false), newPath = await checkedPath(root, to, false);
|
|
851
|
-
const
|
|
1059
|
+
const exists2 = async (path) => lstat2(path).then(() => true, (error) => {
|
|
852
1060
|
if (error.code === "ENOENT") return false;
|
|
853
1061
|
throw error;
|
|
854
1062
|
});
|
|
855
|
-
if (await
|
|
1063
|
+
if (await exists2(oldPath)) {
|
|
856
1064
|
await loadSkill(oldPath);
|
|
857
|
-
if (await
|
|
1065
|
+
if (await exists2(newPath)) throw new Error("Two skill directories exist. Reconcile them before installing; neither was changed.");
|
|
858
1066
|
migrations.push({ from, to });
|
|
859
1067
|
}
|
|
860
1068
|
}
|
|
@@ -916,7 +1124,7 @@ import { lstat as lstat3, open as open3, rename as rename3, unlink as unlink2 }
|
|
|
916
1124
|
import { hostname } from "node:os";
|
|
917
1125
|
import { randomUUID as randomUUID2 } from "node:crypto";
|
|
918
1126
|
var LOCK_PATH = ".editorbrain-operation.lock";
|
|
919
|
-
async function withWorkspaceLock(root, operation) {
|
|
1127
|
+
async function withWorkspaceLock(root, operation, options = {}) {
|
|
920
1128
|
const legacyLock = await checkedPath(root, ".editorharness-operation.lock", false);
|
|
921
1129
|
let legacy;
|
|
922
1130
|
try {
|
|
@@ -940,12 +1148,12 @@ async function withWorkspaceLock(root, operation) {
|
|
|
940
1148
|
await handle.close();
|
|
941
1149
|
const oldRoot = await checkedPath(root, ".editorharness", false);
|
|
942
1150
|
const newRoot = await checkedPath(root, ".editorbrain", false);
|
|
943
|
-
const
|
|
1151
|
+
const exists2 = async (path2) => lstat3(path2).then(() => true, (error) => {
|
|
944
1152
|
if (error.code === "ENOENT") return false;
|
|
945
1153
|
throw error;
|
|
946
1154
|
});
|
|
947
|
-
if (await
|
|
948
|
-
if (await
|
|
1155
|
+
if (options.migrateLegacy !== false && await exists2(oldRoot)) {
|
|
1156
|
+
if (await exists2(newRoot)) throw new CliError("WORKSPACE_PATH_CONFLICT", "This folder contains two versions of the workspace directory.", "Reconcile the directories before continuing; neither was changed.");
|
|
949
1157
|
await rename3(oldRoot, newRoot);
|
|
950
1158
|
}
|
|
951
1159
|
return await operation();
|
|
@@ -1067,7 +1275,7 @@ async function configureMcp(agent, directory, url, noBrowser, runner = runHost)
|
|
|
1067
1275
|
const version = await runner(agent, ["--version"], directory);
|
|
1068
1276
|
if (version.code !== 0) throw new CliError("AGENT_UNAVAILABLE", `${agent} CLI is unavailable.`, "Install the official agent CLI, or use setup --skip-mcp.");
|
|
1069
1277
|
const inspect = await runner(agent, agent === "codex" ? ["mcp", "list", "--json"] : ["mcp", "get", "editorbrain"], directory);
|
|
1070
|
-
let
|
|
1278
|
+
let exists2 = false;
|
|
1071
1279
|
if (agent === "codex") {
|
|
1072
1280
|
if (inspect.code !== 0) throw new CliError("MCP_INSPECTION_FAILED", "Could not inspect Codex MCP configuration.", "Inspect it manually; setup did not overwrite it.");
|
|
1073
1281
|
let list;
|
|
@@ -1077,12 +1285,12 @@ async function configureMcp(agent, directory, url, noBrowser, runner = runHost)
|
|
|
1077
1285
|
throw new CliError("MCP_INSPECTION_FAILED", "Codex returned an unsupported configuration format.", "Inspect MCP configuration manually.");
|
|
1078
1286
|
}
|
|
1079
1287
|
if (!Array.isArray(list)) throw new CliError("MCP_INSPECTION_FAILED", "Unsupported MCP configuration response.", "Inspect MCP configuration manually.");
|
|
1080
|
-
|
|
1288
|
+
exists2 = list.some((item) => item?.name === "editorbrain");
|
|
1081
1289
|
} else {
|
|
1082
|
-
|
|
1083
|
-
if (!
|
|
1290
|
+
exists2 = inspect.code === 0;
|
|
1291
|
+
if (!exists2 && !/^No MCP server named "editorbrain"[.\s]/.test(inspect.stderr + inspect.stdout)) throw new CliError("MCP_INSPECTION_FAILED", "Could not inspect Claude MCP configuration.", "Inspect it manually; setup did not overwrite it.");
|
|
1084
1292
|
}
|
|
1085
|
-
if (
|
|
1293
|
+
if (exists2) return { agent, status: "preserved", next: "An existing editorbrain server was preserved. Check its endpoint and authentication, then " + next, hostVerified: false };
|
|
1086
1294
|
const added = await runner(agent, args, directory, true);
|
|
1087
1295
|
return { agent, status: added.code === 0 ? "registered" : "action_required", next, hostVerified: false };
|
|
1088
1296
|
}
|
|
@@ -1137,10 +1345,10 @@ async function setup(options, runner = runHost) {
|
|
|
1137
1345
|
}
|
|
1138
1346
|
|
|
1139
1347
|
// packages/cli/src/index.ts
|
|
1140
|
-
import { chmod, lstat as
|
|
1141
|
-
import { constants as
|
|
1348
|
+
import { chmod, lstat as lstat5, mkdir as mkdir3, open as open5 } from "node:fs/promises";
|
|
1349
|
+
import { constants as constants5 } from "node:fs";
|
|
1142
1350
|
import { homedir } from "node:os";
|
|
1143
|
-
import { basename, dirname as
|
|
1351
|
+
import { basename, dirname as dirname3, extname, join as join2, resolve as resolve4 } from "node:path";
|
|
1144
1352
|
|
|
1145
1353
|
// packages/cli/src/device-auth.ts
|
|
1146
1354
|
import { spawn as spawn2 } from "node:child_process";
|
|
@@ -1342,6 +1550,225 @@ async function prepareDirectionContext(root, client, projectId) {
|
|
|
1342
1550
|
};
|
|
1343
1551
|
}
|
|
1344
1552
|
|
|
1553
|
+
// packages/cli/src/style-authoring.ts
|
|
1554
|
+
import { createHash as createHash2 } from "node:crypto";
|
|
1555
|
+
import { constants as constants4 } from "node:fs";
|
|
1556
|
+
import { lstat as lstat4, mkdir as mkdir2, open as open4, readdir as readdir3, rmdir, unlink as unlink3 } from "node:fs/promises";
|
|
1557
|
+
import { dirname as dirname2 } from "node:path";
|
|
1558
|
+
function styleId(id) {
|
|
1559
|
+
try {
|
|
1560
|
+
return assertIdentifier(id);
|
|
1561
|
+
} catch {
|
|
1562
|
+
throw new CliError("INVALID_ARGUMENT", "A style ID must use lowercase letters, numbers, _ or - (1\u201380 characters).", "Use styles create <style-id> or styles check <style-id>.");
|
|
1563
|
+
}
|
|
1564
|
+
}
|
|
1565
|
+
async function exists(path) {
|
|
1566
|
+
try {
|
|
1567
|
+
await lstat4(path);
|
|
1568
|
+
return true;
|
|
1569
|
+
} catch (error) {
|
|
1570
|
+
if (error.code === "ENOENT") return false;
|
|
1571
|
+
throw error;
|
|
1572
|
+
}
|
|
1573
|
+
}
|
|
1574
|
+
async function preflightCreate(root, id, files) {
|
|
1575
|
+
for (const prefix of [".editorbrain", ".editorharness"]) {
|
|
1576
|
+
const path = `${prefix}/styles/${id}`;
|
|
1577
|
+
if (await exists(await checkedPath(root, path, false))) {
|
|
1578
|
+
throw new CliError("STYLE_EXISTS", `Style ${id} already exists in this workspace.`, "Choose a new style ID or edit the existing style documents; creation never replaces a style.");
|
|
1579
|
+
}
|
|
1580
|
+
}
|
|
1581
|
+
if (await exists(await checkedPath(root, ".editorharness", false))) {
|
|
1582
|
+
throw new CliError("WORKSPACE_PATH_CONFLICT", "This workspace still has a legacy portable directory.", "Migrate it using the existing workspace pull flow, or create the style in a separate folder. No style or project was changed.");
|
|
1583
|
+
}
|
|
1584
|
+
for (const path of Object.keys(files)) {
|
|
1585
|
+
if (!path.startsWith(`.editorbrain/styles/${id}/`)) throw new Error("Style template escaped its destination");
|
|
1586
|
+
const target = await checkedPath(root, path);
|
|
1587
|
+
if (await exists(target)) throw new CliError("STYLE_EXISTS", `Refusing to replace existing file: ${path}`, "Choose a new style ID.");
|
|
1588
|
+
}
|
|
1589
|
+
}
|
|
1590
|
+
async function makeDirectories(root, path, created) {
|
|
1591
|
+
const segments = path.split("/");
|
|
1592
|
+
for (let i = 1; i <= segments.length; i++) {
|
|
1593
|
+
const relative2 = segments.slice(0, i).join("/");
|
|
1594
|
+
const target = await checkedPath(root, relative2, false);
|
|
1595
|
+
try {
|
|
1596
|
+
await mkdir2(target);
|
|
1597
|
+
const info = await lstat4(target);
|
|
1598
|
+
created.push({ path: relative2, dev: info.dev, ino: info.ino });
|
|
1599
|
+
} catch (error) {
|
|
1600
|
+
if (error.code !== "EEXIST") throw error;
|
|
1601
|
+
if (!(await lstat4(await checkedPath(root, relative2, false))).isDirectory()) throw new Error(`Not a directory: ${relative2}`);
|
|
1602
|
+
}
|
|
1603
|
+
}
|
|
1604
|
+
}
|
|
1605
|
+
async function cleanCreated(root, paths, directory) {
|
|
1606
|
+
for (const item of [...paths].reverse()) {
|
|
1607
|
+
try {
|
|
1608
|
+
const target = await checkedPath(root, item.path, false);
|
|
1609
|
+
const info = await lstat4(target);
|
|
1610
|
+
if (info.dev !== item.dev || info.ino !== item.ino || info.isSymbolicLink()) continue;
|
|
1611
|
+
if (directory) await rmdir(target);
|
|
1612
|
+
else await unlink3(target);
|
|
1613
|
+
} catch {
|
|
1614
|
+
}
|
|
1615
|
+
}
|
|
1616
|
+
}
|
|
1617
|
+
async function createLocalStyle(options) {
|
|
1618
|
+
const id = styleId(options.id);
|
|
1619
|
+
if (options.name !== void 0 && (!options.name.trim() || options.name.length > 200 || /[\x00-\x1f\x7f]/.test(options.name))) {
|
|
1620
|
+
throw new CliError("INVALID_ARGUMENT", "--name must contain 1\u2013200 characters without control characters.", "Use a short, descriptive style name.");
|
|
1621
|
+
}
|
|
1622
|
+
const files = createStyle({ id, name: options.name });
|
|
1623
|
+
const root = await ensureRoot(options.directory, true);
|
|
1624
|
+
await preflightCreate(root, id, files);
|
|
1625
|
+
return withWorkspaceLock(root, async () => {
|
|
1626
|
+
await preflightCreate(root, id, files);
|
|
1627
|
+
const createdFiles = [];
|
|
1628
|
+
const createdDirectories = [];
|
|
1629
|
+
try {
|
|
1630
|
+
for (const [path, content] of Object.entries(files)) {
|
|
1631
|
+
await makeDirectories(root, dirname2(path), createdDirectories);
|
|
1632
|
+
const target = await checkedPath(root, path);
|
|
1633
|
+
const handle = await open4(target, constants4.O_WRONLY | constants4.O_CREAT | constants4.O_EXCL | constants4.O_NOFOLLOW, 420);
|
|
1634
|
+
try {
|
|
1635
|
+
const info = await handle.stat();
|
|
1636
|
+
createdFiles.push({ path, dev: info.dev, ino: info.ino });
|
|
1637
|
+
await handle.writeFile(content, "utf8");
|
|
1638
|
+
} finally {
|
|
1639
|
+
await handle.close();
|
|
1640
|
+
}
|
|
1641
|
+
}
|
|
1642
|
+
} catch (error) {
|
|
1643
|
+
await cleanCreated(root, createdFiles, false);
|
|
1644
|
+
await cleanCreated(root, createdDirectories, true);
|
|
1645
|
+
throw error;
|
|
1646
|
+
}
|
|
1647
|
+
return {
|
|
1648
|
+
root,
|
|
1649
|
+
id,
|
|
1650
|
+
status: "draft",
|
|
1651
|
+
created: Object.keys(files),
|
|
1652
|
+
mediaUploaded: false,
|
|
1653
|
+
next: `Read references/style-creator.md in the EditorBrain skill. Edit .editorbrain/styles/${id}/style.md, effects.md, music.md, resources.json and examples.json, and document evidence actually reviewed; then run editorbrain styles check ${id} --dir <workspace>. Assess adaptation using the actual material in the next requested edit; structural checks do not assess editorial quality.`
|
|
1654
|
+
};
|
|
1655
|
+
}, { migrateLegacy: false });
|
|
1656
|
+
}
|
|
1657
|
+
async function readStyle(root, id) {
|
|
1658
|
+
const files = /* @__PURE__ */ Object.create(null);
|
|
1659
|
+
const issues = [];
|
|
1660
|
+
const directories = [];
|
|
1661
|
+
for (const prefix of [".editorbrain", ".editorharness"]) {
|
|
1662
|
+
const path = `${prefix}/styles/${id}`;
|
|
1663
|
+
try {
|
|
1664
|
+
const target = await checkedPath(root, path, false);
|
|
1665
|
+
if (!await exists(target)) continue;
|
|
1666
|
+
if (!(await lstat4(target)).isDirectory()) {
|
|
1667
|
+
issues.push({ path, message: "Style path is not a directory." });
|
|
1668
|
+
continue;
|
|
1669
|
+
}
|
|
1670
|
+
directories.push(path);
|
|
1671
|
+
const entries = await readdir3(target, { withFileTypes: true });
|
|
1672
|
+
for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
|
|
1673
|
+
if (!/\.(json|md)$/.test(entry.name)) continue;
|
|
1674
|
+
const document = `${path}/${entry.name}`;
|
|
1675
|
+
if (!entry.isFile()) {
|
|
1676
|
+
issues.push({ path: document, message: "Style document must be a regular file, not a symlink or directory." });
|
|
1677
|
+
continue;
|
|
1678
|
+
}
|
|
1679
|
+
try {
|
|
1680
|
+
files[document] = await readText(root, document);
|
|
1681
|
+
} catch (error) {
|
|
1682
|
+
issues.push({ path: document, message: `Cannot safely read style document: ${error.message}` });
|
|
1683
|
+
}
|
|
1684
|
+
}
|
|
1685
|
+
} catch (error) {
|
|
1686
|
+
issues.push({ path, message: `Cannot safely read style directory: ${error.message}` });
|
|
1687
|
+
}
|
|
1688
|
+
}
|
|
1689
|
+
if (directories.length > 1) issues.push({ path: `.editorbrain/styles/${id}`, message: "Style exists in both current and legacy directories. Reconcile the duplicate before checking it." });
|
|
1690
|
+
return { files, issues };
|
|
1691
|
+
}
|
|
1692
|
+
async function checkLocalResource(root, path, expected) {
|
|
1693
|
+
safeStyleResourcePath(path);
|
|
1694
|
+
const target = await checkedPath(root, path, false);
|
|
1695
|
+
const handle = await open4(target, constants4.O_RDONLY | constants4.O_NOFOLLOW | constants4.O_NONBLOCK);
|
|
1696
|
+
try {
|
|
1697
|
+
const before = await handle.stat();
|
|
1698
|
+
if (!before.isFile()) throw new Error("Resource is not a regular file.");
|
|
1699
|
+
if (!expected) return {};
|
|
1700
|
+
const hash = createHash2("sha256");
|
|
1701
|
+
for await (const chunk of handle.createReadStream({ autoClose: false })) hash.update(chunk);
|
|
1702
|
+
const after = await handle.stat();
|
|
1703
|
+
const current = await lstat4(await checkedPath(root, path, false));
|
|
1704
|
+
if (before.size !== after.size || before.mtimeMs !== after.mtimeMs || before.ctimeMs !== after.ctimeMs || current.dev !== before.dev || current.ino !== before.ino) {
|
|
1705
|
+
throw new Error("Resource changed while its hash was being checked; check it again.");
|
|
1706
|
+
}
|
|
1707
|
+
return { actualSha256: hash.digest("hex") };
|
|
1708
|
+
} finally {
|
|
1709
|
+
await handle.close();
|
|
1710
|
+
}
|
|
1711
|
+
}
|
|
1712
|
+
async function checkLocalStyle(options) {
|
|
1713
|
+
const id = styleId(options.id);
|
|
1714
|
+
const root = await ensureRoot(options.directory);
|
|
1715
|
+
const local = await readStyle(root, id);
|
|
1716
|
+
const structural = checkStyle(local.files, id);
|
|
1717
|
+
const localIssues = [...local.issues];
|
|
1718
|
+
const warnings = [...structural.warnings];
|
|
1719
|
+
const resources = [];
|
|
1720
|
+
const resourcePath = Object.keys(local.files).find((path) => path.endsWith("/resources.json"));
|
|
1721
|
+
let document;
|
|
1722
|
+
if (resourcePath) {
|
|
1723
|
+
try {
|
|
1724
|
+
const parsed = JSON.parse(local.files[resourcePath]);
|
|
1725
|
+
if (validateStyleResources(parsed, id).valid) document = parsed;
|
|
1726
|
+
} catch {
|
|
1727
|
+
}
|
|
1728
|
+
}
|
|
1729
|
+
for (const resource of document?.resources ?? []) {
|
|
1730
|
+
const result = { id: resource.id, source: resource.source.type, required: resource.required, status: "unverified" };
|
|
1731
|
+
resources.push(result);
|
|
1732
|
+
if (resource.source.type !== "local") {
|
|
1733
|
+
warnings.push(`Resource ${resource.id}: ${resource.source.type === "asset" ? "remote asset availability and access" : "installed or external requirement"} not verified by this local check.`);
|
|
1734
|
+
continue;
|
|
1735
|
+
}
|
|
1736
|
+
result.path = resource.source.path;
|
|
1737
|
+
result.expectedSha256 = resource.source.sha256;
|
|
1738
|
+
let message;
|
|
1739
|
+
try {
|
|
1740
|
+
const checked = await checkLocalResource(root, resource.source.path, resource.source.sha256);
|
|
1741
|
+
result.actualSha256 = checked.actualSha256;
|
|
1742
|
+
result.status = resource.source.sha256 ? "verified" : "present";
|
|
1743
|
+
if (resource.source.sha256 && checked.actualSha256 !== resource.source.sha256) {
|
|
1744
|
+
result.status = "hash_mismatch";
|
|
1745
|
+
message = "Resource SHA-256 does not match resources.json.";
|
|
1746
|
+
} else if (!resource.source.sha256) warnings.push(`Resource ${resource.id}: local file is present, but its content hash is not pinned.`);
|
|
1747
|
+
} catch (error) {
|
|
1748
|
+
result.status = error.code === "ENOENT" ? "missing" : "invalid";
|
|
1749
|
+
message = result.status === "missing" ? "Local resource is missing." : `Cannot safely verify resource: ${error.message}`;
|
|
1750
|
+
}
|
|
1751
|
+
if (message) {
|
|
1752
|
+
const detail = `Resource ${resource.id}: ${message}`;
|
|
1753
|
+
if (resource.required) localIssues.push({ path: resource.source.path, message: detail });
|
|
1754
|
+
else warnings.push(`Optional ${detail}`);
|
|
1755
|
+
}
|
|
1756
|
+
}
|
|
1757
|
+
const issues = [...structural.issues, ...localIssues];
|
|
1758
|
+
return {
|
|
1759
|
+
root,
|
|
1760
|
+
id,
|
|
1761
|
+
...structural,
|
|
1762
|
+
valid: issues.length === 0,
|
|
1763
|
+
issues,
|
|
1764
|
+
warnings,
|
|
1765
|
+
localIssues,
|
|
1766
|
+
resources,
|
|
1767
|
+
mediaUploaded: false,
|
|
1768
|
+
quality: "not_assessed"
|
|
1769
|
+
};
|
|
1770
|
+
}
|
|
1771
|
+
|
|
1345
1772
|
// packages/cli/src/index.ts
|
|
1346
1773
|
var HELP = `EditorBrain \u2014 portable editorial context for AI video agents
|
|
1347
1774
|
|
|
@@ -1361,7 +1788,18 @@ editorbrain pull [--project id | --all] [--dir folder] [--dry-run]
|
|
|
1361
1788
|
editorbrain push [--dir folder] [--dry-run]
|
|
1362
1789
|
editorbrain projects list
|
|
1363
1790
|
editorbrain documents list [--prefix path]
|
|
1364
|
-
editorbrain documents get <path>
|
|
1791
|
+
editorbrain documents get <path> [--revision N]
|
|
1792
|
+
editorbrain documents history <path> [--before N]
|
|
1793
|
+
editorbrain documents restore <path> --revision N --expected-revision N
|
|
1794
|
+
editorbrain memory list --scope brand|style|project --id ID
|
|
1795
|
+
editorbrain memory add --scope brand|style|project --id ID --text TEXT --status suggested|confirmed --expected-revision N
|
|
1796
|
+
editorbrain memory confirm|disable ENTRY --scope brand|style|project --id ID --expected-revision N
|
|
1797
|
+
editorbrain feedback add --project ID --text TEXT
|
|
1798
|
+
editorbrain improvements list --brand ID
|
|
1799
|
+
editorbrain improvements accept RUN --index N --expected-revision N
|
|
1800
|
+
editorbrain review list --project ID
|
|
1801
|
+
editorbrain review create --project ID --asset SHA --title TEXT --kind sample|render --days 7 --share-authorized
|
|
1802
|
+
editorbrain review revoke ID
|
|
1365
1803
|
editorbrain workflow check [--project id] [--previous-version hash]
|
|
1366
1804
|
editorbrain search brief --file <editorial-brief.json> [--project id] [--mode styles|events|both] [--review direct|directions|sample]
|
|
1367
1805
|
editorbrain search edit [--script file | --video file | --asset sha256 | --project id] [--requirements text] [--assets tag,tag] [--mode styles|events|both] [--review direct|directions|sample]
|
|
@@ -1371,6 +1809,8 @@ editorbrain library media <video-id> --sha256 media-hash --out file
|
|
|
1371
1809
|
editorbrain narrative search [query] [--kind hook|cta|...] [--channel visual|spoken|...] [--limit 10]
|
|
1372
1810
|
editorbrain narrative get <video-id> [--sha256 document-hash] [--event-offset 0] [--relation-offset 0] [--limit 50]
|
|
1373
1811
|
editorbrain styles list
|
|
1812
|
+
editorbrain styles create <style-id> [--name name] [--dir folder]
|
|
1813
|
+
editorbrain styles check <style-id> [--dir folder]
|
|
1374
1814
|
editorbrain styles recommend <project-id> [--assets tag,tag] [--capabilities tag,tag] [--style pack-id] [--duration seconds]
|
|
1375
1815
|
editorbrain direction draft <project-id> [--assets tag,tag] [--capabilities tag,tag] [--style pack-id] [--duration seconds] [--out new-folder]
|
|
1376
1816
|
editorbrain assets list [--limit 200] [--cursor opaque-cursor]
|
|
@@ -1385,14 +1825,15 @@ Secrets go to a user config with mode 0600, never the portable project.
|
|
|
1385
1825
|
Resolve writes the source-hash lock. Export produces a handoff, not a rendered video.
|
|
1386
1826
|
Push/pull preserve conflicting changes; media uploads use the separate assets command.
|
|
1387
1827
|
Skill install works offline, preserves existing instructions, and defaults to both agents.
|
|
1828
|
+
Styles create/check work locally without login or media upload; checks do not certify render quality.
|
|
1388
1829
|
Keys create stores the returned secret in the private user config; stdout has no key.
|
|
1389
1830
|
Output is JSON except help; login progress/code goes to stderr. --url overrides the configured endpoint.
|
|
1390
1831
|
`;
|
|
1391
1832
|
function parseArgs(argv) {
|
|
1392
1833
|
const positional = [];
|
|
1393
1834
|
const flags = {};
|
|
1394
|
-
const booleans = /* @__PURE__ */ new Set(["help", "version", "offline", "dry-run", "key-stdin", "invite-stdin", "no-browser", "skip-mcp", "all"]);
|
|
1395
|
-
const known = /* @__PURE__ */ new Set(["dir", "brand", "style", "project", "topic", "goal", "name", "adapter", "out", "url", "document", "mime", "label", "role", "limit", "cursor", "timeout", "assets", "capabilities", "duration", "agent", "workspace", "prefix", "sha256", "kind", "channel", "epistemic-status", "event-offset", "relation-offset", "script", "video", "asset", "requirements", "mode", "review", "previous-version", "file", ...booleans]);
|
|
1835
|
+
const booleans = /* @__PURE__ */ new Set(["help", "version", "offline", "dry-run", "key-stdin", "invite-stdin", "no-browser", "skip-mcp", "all", "share-authorized"]);
|
|
1836
|
+
const known = /* @__PURE__ */ new Set(["dir", "brand", "style", "project", "topic", "goal", "name", "adapter", "out", "url", "document", "mime", "label", "role", "limit", "cursor", "timeout", "assets", "capabilities", "duration", "agent", "workspace", "prefix", "sha256", "kind", "channel", "epistemic-status", "event-offset", "relation-offset", "script", "video", "asset", "requirements", "mode", "review", "previous-version", "file", "revision", "expected-revision", "before", "scope", "id", "text", "conditions", "status", "index", "title", "days", ...booleans]);
|
|
1396
1837
|
for (let i = 0; i < argv.length; i++) {
|
|
1397
1838
|
const arg = argv[i];
|
|
1398
1839
|
if (!arg.startsWith("--")) {
|
|
@@ -1421,13 +1862,13 @@ async function configRoot(create = false) {
|
|
|
1421
1862
|
let path = process.env.EDITORBRAIN_CONFIG_DIR ?? process.env.EDITORHARNESS_CONFIG_DIR ?? join2(homedir(), ".config", "editorbrain");
|
|
1422
1863
|
if (!create && !process.env.EDITORBRAIN_CONFIG_DIR && !process.env.EDITORHARNESS_CONFIG_DIR) {
|
|
1423
1864
|
try {
|
|
1424
|
-
await
|
|
1865
|
+
await lstat5(path);
|
|
1425
1866
|
} catch (error) {
|
|
1426
1867
|
if (error.code !== "ENOENT") throw error;
|
|
1427
1868
|
path = join2(homedir(), ".config", "editorharness");
|
|
1428
1869
|
}
|
|
1429
1870
|
}
|
|
1430
|
-
if (create) await
|
|
1871
|
+
if (create) await mkdir3(path, { recursive: true, mode: 448 });
|
|
1431
1872
|
return ensureRoot(path);
|
|
1432
1873
|
}
|
|
1433
1874
|
async function loadConfig() {
|
|
@@ -1472,7 +1913,7 @@ async function init(args) {
|
|
|
1472
1913
|
for (const path of Object.keys(files)) {
|
|
1473
1914
|
const target = await checkedPath(root, path);
|
|
1474
1915
|
try {
|
|
1475
|
-
await
|
|
1916
|
+
await lstat5(target);
|
|
1476
1917
|
throw new Error(`Refusing to replace existing file: ${path}`);
|
|
1477
1918
|
} catch (error) {
|
|
1478
1919
|
if (error.code !== "ENOENT") throw error;
|
|
@@ -1484,13 +1925,13 @@ async function init(args) {
|
|
|
1484
1925
|
for (const path of directories) {
|
|
1485
1926
|
const target = await checkedPath(root, path, false);
|
|
1486
1927
|
try {
|
|
1487
|
-
if (!(await
|
|
1928
|
+
if (!(await lstat5(target)).isDirectory()) throw new Error(`Not a directory: ${path}`);
|
|
1488
1929
|
} catch (error) {
|
|
1489
1930
|
if (error.code !== "ENOENT") throw error;
|
|
1490
1931
|
}
|
|
1491
1932
|
}
|
|
1492
1933
|
for (const [path, content] of Object.entries(files)) await atomicWrite(root, path, content, { overwrite: false });
|
|
1493
|
-
for (const path of directories) await
|
|
1934
|
+
for (const path of directories) await mkdir3(await checkedPath(root, path, false), { recursive: true });
|
|
1494
1935
|
const guidance = await installGuidance();
|
|
1495
1936
|
const entryPoints = guidance.readInstructions.filter((item) => item.entryPointStatus === "created").map((item) => item.entryPoint);
|
|
1496
1937
|
output({ root, created: Object.keys(files), entryPoints, guidance, next: `Edit brand tokens and brief, then run editorbrain resolve ${project}` });
|
|
@@ -1596,6 +2037,20 @@ async function main() {
|
|
|
1596
2037
|
output({ endpoint: client.url, ...await client.me() });
|
|
1597
2038
|
return;
|
|
1598
2039
|
}
|
|
2040
|
+
if (command === "styles" && (subcommand === "create" || subcommand === "check")) {
|
|
2041
|
+
const allowed = new Set(subcommand === "create" ? ["dir", "name"] : ["dir"]);
|
|
2042
|
+
if (args.positional.length !== 3 || Object.keys(args.flags).some((flag) => !allowed.has(flag))) {
|
|
2043
|
+
throw new CliError("INVALID_ARGUMENT", `Use styles ${subcommand} <style-id> [--dir folder]${subcommand === "create" ? " [--name name]" : ""}.`, "Style authoring runs locally and accepts only the documented options.");
|
|
2044
|
+
}
|
|
2045
|
+
const options = { directory: str(args, "dir") ?? process.cwd(), id: argument };
|
|
2046
|
+
if (subcommand === "create") output(await createLocalStyle({ ...options, name: str(args, "name") }));
|
|
2047
|
+
else {
|
|
2048
|
+
const result = await checkLocalStyle(options);
|
|
2049
|
+
output(result);
|
|
2050
|
+
if (!result.valid) process.exitCode = 1;
|
|
2051
|
+
}
|
|
2052
|
+
return;
|
|
2053
|
+
}
|
|
1599
2054
|
if (command === "styles" || command === "direction") {
|
|
1600
2055
|
const client = new Client(await config(args));
|
|
1601
2056
|
if (command === "styles" && subcommand === "list") {
|
|
@@ -1621,7 +2076,7 @@ async function main() {
|
|
|
1621
2076
|
const outRoot = await ensureRoot(resolve4(str(args, "dir") ?? process.cwd(), destination), true);
|
|
1622
2077
|
for (const path of Object.keys(result.files)) {
|
|
1623
2078
|
try {
|
|
1624
|
-
await
|
|
2079
|
+
await lstat5(await checkedPath(outRoot, path, false));
|
|
1625
2080
|
throw new Error(`Refusing to replace existing proposal: ${path}`);
|
|
1626
2081
|
} catch (error) {
|
|
1627
2082
|
if (error.code !== "ENOENT") throw error;
|
|
@@ -1667,12 +2122,57 @@ async function main() {
|
|
|
1667
2122
|
return;
|
|
1668
2123
|
}
|
|
1669
2124
|
if (subcommand === "get" && argument) {
|
|
1670
|
-
const doc = await client.
|
|
2125
|
+
const doc = await client.request(`/v1/documents/${argument.split("/").map(encodeURIComponent).join("/")}${str(args, "revision") ? "?revision=" + encodeURIComponent(str(args, "revision")) : ""}`);
|
|
1671
2126
|
if (await sha256(doc.content) !== doc.sha256) throw new Error("Document integrity failed");
|
|
1672
2127
|
output(doc);
|
|
1673
2128
|
return;
|
|
1674
2129
|
}
|
|
1675
|
-
|
|
2130
|
+
if (subcommand === "history" && argument) {
|
|
2131
|
+
output(await client.request("/v1/history?" + new URLSearchParams({ path: argument, ...str(args, "before") ? { before: str(args, "before") } : {} })));
|
|
2132
|
+
return;
|
|
2133
|
+
}
|
|
2134
|
+
if (subcommand === "restore" && argument) {
|
|
2135
|
+
output(await client.request("/v1/history/restore", { method: "POST", body: { path: argument, revision: Number(str(args, "revision")), expectedRevision: Number(str(args, "expected-revision")) } }));
|
|
2136
|
+
return;
|
|
2137
|
+
}
|
|
2138
|
+
throw new Error("Use documents list/get/history/restore. Restore requires --revision and --expected-revision.");
|
|
2139
|
+
}
|
|
2140
|
+
if (command === "memory" || command === "feedback" || command === "review" || command === "improvements") {
|
|
2141
|
+
const client = new Client(await config(args));
|
|
2142
|
+
if (command === "memory" && subcommand === "list") {
|
|
2143
|
+
output(await client.request("/v1/memory?" + new URLSearchParams({ scope: str(args, "scope") ?? "brand", scopeId: str(args, "id") ?? "" })));
|
|
2144
|
+
return;
|
|
2145
|
+
}
|
|
2146
|
+
if (command === "memory" && ["add", "confirm", "disable"].includes(subcommand ?? "")) {
|
|
2147
|
+
output(await client.request("/v1/memory", { method: "POST", body: { scope: str(args, "scope") ?? "brand", scopeId: str(args, "id"), action: subcommand, entryId: argument, expectedRevision: Number(str(args, "expected-revision")), text: str(args, "text"), conditions: str(args, "conditions") ?? "", status: str(args, "status") ?? "suggested" } }));
|
|
2148
|
+
return;
|
|
2149
|
+
}
|
|
2150
|
+
if (command === "feedback" && subcommand === "add") {
|
|
2151
|
+
output(await client.request("/v1/feedback", { method: "POST", body: { projectId: str(args, "project"), text: str(args, "text") } }));
|
|
2152
|
+
return;
|
|
2153
|
+
}
|
|
2154
|
+
if (command === "improvements" && subcommand === "list") {
|
|
2155
|
+
output(await client.request("/v1/improvements?" + new URLSearchParams({ brandId: str(args, "brand") ?? "" })));
|
|
2156
|
+
return;
|
|
2157
|
+
}
|
|
2158
|
+
if (command === "improvements" && subcommand === "accept") {
|
|
2159
|
+
output(await client.request("/v1/improvements/accept", { method: "POST", body: { runId: argument, index: Number(str(args, "index")), expectedRevision: Number(str(args, "expected-revision")) } }));
|
|
2160
|
+
return;
|
|
2161
|
+
}
|
|
2162
|
+
if (command === "review" && subcommand === "list") {
|
|
2163
|
+
output(await client.request("/v1/reviews?" + new URLSearchParams({ projectId: str(args, "project") ?? "" })));
|
|
2164
|
+
return;
|
|
2165
|
+
}
|
|
2166
|
+
if (command === "review" && subcommand === "revoke" && argument) {
|
|
2167
|
+
output(await client.request("/v1/reviews/" + encodeURIComponent(argument), { method: "DELETE" }));
|
|
2168
|
+
return;
|
|
2169
|
+
}
|
|
2170
|
+
if (command === "review" && subcommand === "create") {
|
|
2171
|
+
if (!args.flags["share-authorized"]) throw new Error("Sharing requires explicit user authorization and --share-authorized. Anyone with the link can view.");
|
|
2172
|
+
output(await client.request("/v1/reviews", { method: "POST", body: { projectId: str(args, "project"), assetId: str(args, "asset"), title: str(args, "title"), kind: str(args, "kind") ?? "sample", days: Number(str(args, "days") ?? 7), shareAuthorized: true } }));
|
|
2173
|
+
return;
|
|
2174
|
+
}
|
|
2175
|
+
throw new Error("Use memory list/add/confirm/disable, feedback add, improvements list/accept, review list/create/revoke. See help for flags.");
|
|
1676
2176
|
}
|
|
1677
2177
|
if (command === "assets") {
|
|
1678
2178
|
const client = new Client(await config(args));
|
|
@@ -1690,7 +2190,7 @@ async function main() {
|
|
|
1690
2190
|
}
|
|
1691
2191
|
if (subcommand === "upload" && argument) {
|
|
1692
2192
|
const target = resolve4(str(args, "dir") ?? process.cwd(), argument);
|
|
1693
|
-
const handle = await
|
|
2193
|
+
const handle = await open5(target, constants5.O_RDONLY | constants5.O_NOFOLLOW);
|
|
1694
2194
|
let bytes;
|
|
1695
2195
|
try {
|
|
1696
2196
|
const info = await handle.stat();
|
|
@@ -1712,7 +2212,7 @@ async function main() {
|
|
|
1712
2212
|
if (subcommand === "download" && argument && str(args, "out")) {
|
|
1713
2213
|
if (!/^[a-f0-9]{64}$/.test(argument)) throw new Error("Asset ID must be its SHA-256");
|
|
1714
2214
|
const destination = resolve4(str(args, "dir") ?? process.cwd(), str(args, "out"));
|
|
1715
|
-
const root2 = await ensureRoot(
|
|
2215
|
+
const root2 = await ensureRoot(dirname3(destination), true);
|
|
1716
2216
|
await checkedPath(root2, basename(destination), false);
|
|
1717
2217
|
const response = await client.media(`/v1/assets/${argument}`);
|
|
1718
2218
|
const bytes = new Uint8Array(await response.arrayBuffer());
|
|
@@ -1747,7 +2247,7 @@ async function main() {
|
|
|
1747
2247
|
if (command === "search" && subcommand === "brief") {
|
|
1748
2248
|
const path = str(args, "file");
|
|
1749
2249
|
if (!path) throw new Error("Provide --file with the editorial brief JSON after inspecting the actual material.");
|
|
1750
|
-
const handle = await
|
|
2250
|
+
const handle = await open5(resolve4(path), constants5.O_RDONLY | constants5.O_NOFOLLOW);
|
|
1751
2251
|
let brief;
|
|
1752
2252
|
try {
|
|
1753
2253
|
const stat = await handle.stat();
|
|
@@ -1769,7 +2269,7 @@ async function main() {
|
|
|
1769
2269
|
const scriptPath = str(args, "script"), videoPath = str(args, "video");
|
|
1770
2270
|
let script, assetId = str(args, "asset");
|
|
1771
2271
|
const readInput = async (path, max) => {
|
|
1772
|
-
const handle = await
|
|
2272
|
+
const handle = await open5(resolve4(path), constants5.O_RDONLY | constants5.O_NOFOLLOW);
|
|
1773
2273
|
try {
|
|
1774
2274
|
const stat = await handle.stat();
|
|
1775
2275
|
if (!stat.isFile() || stat.size < 1 || stat.size > max) throw new Error(`Input must be a regular file of at most ${max} bytes.`);
|
|
@@ -1818,7 +2318,7 @@ async function main() {
|
|
|
1818
2318
|
const hash = str(args, "sha256");
|
|
1819
2319
|
if (!hash || !/^[a-f0-9]{64}$/.test(hash)) throw new Error("--sha256 must be a media SHA-256 from library get.");
|
|
1820
2320
|
const destination = resolve4(str(args, "dir") ?? process.cwd(), str(args, "out"));
|
|
1821
|
-
const root2 = await ensureRoot(
|
|
2321
|
+
const root2 = await ensureRoot(dirname3(destination), true);
|
|
1822
2322
|
await checkedPath(root2, basename(destination), false);
|
|
1823
2323
|
const response = await client.media(`/v1/library/${encodeURIComponent(argument)}/media/${hash}`);
|
|
1824
2324
|
const reader = response.body?.getReader();
|