editorbrain 0.10.0 → 0.12.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.
@@ -30,6 +30,307 @@ function resolveMemory(files, p) {
30
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
31
  }
32
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 text2 = (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
+ text2(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
+ text2(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 finish2 = (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 finish2([]);
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 finish2([]);
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 finish2(documents);
201
+ }
202
+
203
+ // packages/core/src/production-plan.ts
204
+ var record = (v) => !!v && typeof v === "object" && !Array.isArray(v);
205
+ var positive = (v) => typeof v === "number" && Number.isFinite(v) && v > 0;
206
+ var nonnegative = (v) => v === 0 || positive(v);
207
+ var hash = (v) => typeof v === "string" && /^[a-f0-9]{64}$/.test(v);
208
+ var text = (v) => typeof v === "string" && v.trim().length > 0 && v.length <= 2e3;
209
+ var range = (v) => record(v) && nonnegative(v.startSeconds) && positive(v.endSeconds) && v.endSeconds > v.startSeconds;
210
+ var MAX_ENTRIES = 2e3;
211
+ function validateProductionPlan(value) {
212
+ const issues = [];
213
+ if (!record(value) || value.schemaVersion !== 1 || value.kind !== "editorbrain.production-plan") return { valid: false, issues: ["Expected schemaVersion 1 and kind editorbrain.production-plan."] };
214
+ if (Object.keys(value).some((key) => !["schemaVersion", "kind", "inspections", "uses"].includes(key))) issues.push("Unknown plan fields; keep editorial notes in direction.md and use the documented plan schema.");
215
+ if (!Array.isArray(value.inspections) || value.inspections.length > MAX_ENTRIES) issues.push("inspections must be an array of at most 2000 observations.");
216
+ else value.inspections.forEach((item, index) => {
217
+ const label = `inspections[${index}]`;
218
+ if (!record(item) || !hash(item.sourceSha256) || !["visual", "motion", "audio"].includes(String(item.modality)) || !["sampled_frames", "playback", "listening"].includes(String(item.method))) {
219
+ issues.push(`${label}: invalid source hash, modality or method.`);
220
+ return;
221
+ }
222
+ if (Object.keys(item).some((key) => !["sourceSha256", "modality", "method", "intervals", "sampledSeconds", "note"].includes(key))) issues.push(`${label}: unsupported field.`);
223
+ if (item.note !== void 0 && !text(item.note)) issues.push(`${label}: note must be nonempty text of at most 2000 characters.`);
224
+ if (item.method === "sampled_frames") {
225
+ if (item.modality !== "visual" || item.intervals !== void 0 || !Array.isArray(item.sampledSeconds) || !item.sampledSeconds.length || item.sampledSeconds.length > MAX_ENTRIES || !item.sampledSeconds.every(nonnegative)) issues.push(`${label}: sampled_frames requires visual modality and sampledSeconds; snapshots never establish continuous interval coverage.`);
226
+ } else {
227
+ if (item.sampledSeconds !== void 0 || !Array.isArray(item.intervals) || !item.intervals.length || item.intervals.length > MAX_ENTRIES || !item.intervals.every(range)) issues.push(`${label}: playback/listening requires valid intervals, not sampledSeconds.`);
228
+ if (item.method === "listening" && item.modality !== "audio" || item.method === "playback" && item.modality === "audio") issues.push(`${label}: listening covers audio; playback covers visual or motion. Record separately what was actually inspected.`);
229
+ }
230
+ });
231
+ if (!Array.isArray(value.uses) || value.uses.length > MAX_ENTRIES) issues.push("uses must be an array of at most 2000 source uses.");
232
+ else {
233
+ const ids = /* @__PURE__ */ new Set();
234
+ value.uses.forEach((item, index) => {
235
+ const label = `uses[${index}]`;
236
+ if (!record(item) || typeof item.id !== "string" || !/^[a-zA-Z0-9_-]{1,80}$/.test(item.id) || !hash(item.sourceSha256) || !range(item.source) || !range(item.timeline)) {
237
+ issues.push(`${label}: requires ID, source hash, source and timeline intervals.`);
238
+ return;
239
+ }
240
+ if (ids.has(item.id)) issues.push(`${label}: duplicate use ID ${item.id}.`);
241
+ ids.add(item.id);
242
+ if (Object.keys(item).some((key) => !["id", "sourceSha256", "source", "timeline", "purpose", "reuseReason"].includes(key))) issues.push(`${label}: unsupported field.`);
243
+ for (const key of ["purpose", "reuseReason"]) if (item[key] !== void 0 && !text(item[key])) issues.push(`${label}: ${key} must be nonempty text of at most 2000 characters.`);
244
+ });
245
+ }
246
+ return { valid: !issues.length, issues };
247
+ }
248
+ function mergeProductionIntervals(intervals) {
249
+ const merged = [];
250
+ for (const interval of [...intervals].sort((a, b) => a.startSeconds - b.startSeconds)) {
251
+ const last = merged.at(-1);
252
+ if (last && interval.startSeconds <= last.endSeconds) last.endSeconds = Math.max(last.endSeconds, interval.endSeconds);
253
+ else merged.push({ ...interval });
254
+ }
255
+ return merged;
256
+ }
257
+ function checkProductionPlan(inventory, value) {
258
+ const checked = validateProductionPlan(value);
259
+ const findings = checked.issues.map((message) => ({ code: "INVALID_PLAN", severity: "error", message }));
260
+ const sources = /* @__PURE__ */ new Map();
261
+ let inventoryComplete = true;
262
+ if (!record(inventory) || inventory.schemaVersion !== 1 || inventory.kind !== "editorbrain.media-inventory" || inventory.summary === true || typeof inventory.complete !== "boolean" || !Array.isArray(inventory.assets) || inventory.assets.length > 1e4) {
263
+ inventoryComplete = false;
264
+ findings.push({ code: "INVALID_INVENTORY", severity: "error", message: "Use the full schemaVersion 1 media inventory saved with --out, not compact stdout. It must declare complete." });
265
+ } else {
266
+ if (inventory.complete === false || Array.isArray(inventory.failures) && inventory.failures.length) {
267
+ inventoryComplete = false;
268
+ findings.push({ code: "INVENTORY_INCOMPLETE", severity: "warning", message: "Inventory traversal or probing was incomplete. This plan check covers only recorded assets." });
269
+ }
270
+ for (const asset of inventory.assets) {
271
+ if (!record(asset) || !hash(asset.sha256) || !(asset.durationSeconds === null || nonnegative(asset.durationSeconds)) || asset.streams !== void 0 && (!Array.isArray(asset.streams) || asset.streams.some((s) => !record(s) || typeof s.type !== "string"))) {
272
+ findings.push({ code: "INVALID_INVENTORY", severity: "error", message: "Inventory assets need a source hash, nonnegative duration or null, and valid optional streams." });
273
+ continue;
274
+ }
275
+ const existing = sources.get(asset.sha256);
276
+ if (existing && existing.durationSeconds !== asset.durationSeconds) findings.push({ code: "INVENTORY_CONFLICT", severity: "error", message: "The same content hash has conflicting durations.", sourceSha256: asset.sha256 });
277
+ sources.set(asset.sha256, asset);
278
+ }
279
+ }
280
+ if (findings.some((f) => f.severity === "error")) return { schemaVersion: 1, kind: "editorbrain.production-plan-check", valid: false, inventoryComplete, findings, coverage: [], sourceFilesReverified: false, quality: "not_assessed", observations: "agent_declared" };
281
+ const plan = value;
282
+ const sourceFor = (sourceSha256) => {
283
+ const source = sources.get(sourceSha256);
284
+ if (!source) findings.push({ code: "UNKNOWN_SOURCE", severity: "error", message: "A source hash is absent from the selected inventory. Refresh the inventory after changing media.", sourceSha256 });
285
+ return source;
286
+ };
287
+ const inBounds = (end, source) => source.durationSeconds === null || end <= source.durationSeconds + 1e-3;
288
+ for (const inspection of plan.inspections) {
289
+ const source = sourceFor(inspection.sourceSha256);
290
+ if (!source) continue;
291
+ if (source.streams && !source.streams.some((s) => s.type === (inspection.modality === "audio" ? "audio" : "video") && (inspection.modality !== "motion" || !s.attachedPicture))) findings.push({ code: "MODALITY_ABSENT", severity: "error", message: `Declared ${inspection.modality} inspection has no matching stream.`, sourceSha256: source.sha256 });
292
+ if ([...inspection.sampledSeconds ?? [], ...(inspection.intervals ?? []).map((r) => r.endSeconds)].some((end) => !inBounds(end, source))) findings.push({ code: "INSPECTION_OUT_OF_BOUNDS", severity: "error", message: "Inspection extends past the source duration.", sourceSha256: source.sha256 });
293
+ }
294
+ const validUses = [];
295
+ for (const use of plan.uses) {
296
+ const source = sourceFor(use.sourceSha256);
297
+ if (!source) continue;
298
+ if (!inBounds(use.source.endSeconds, source)) findings.push({ code: "USE_OUT_OF_BOUNDS", severity: "error", message: "Source interval extends past the source duration.", sourceSha256: source.sha256, useIds: [use.id], interval: use.source });
299
+ else validUses.push(use);
300
+ }
301
+ for (const use of validUses) {
302
+ const source = sources.get(use.sourceSha256);
303
+ const audioOnly = source.streams?.some((s) => s.type === "audio") && !source.streams.some((s) => s.type === "video" && !s.attachedPicture);
304
+ const inspected = mergeProductionIntervals(plan.inspections.filter((i) => i.sourceSha256 === source.sha256 && (audioOnly ? i.modality === "audio" : i.modality === "visual" || i.modality === "motion")).flatMap((i) => i.intervals ?? []).filter((r) => inBounds(r.endSeconds, source)));
305
+ const covered = inspected.reduce((sum, r) => sum + Math.max(0, Math.min(use.source.endSeconds, r.endSeconds) - Math.max(use.source.startSeconds, r.startSeconds)), 0);
306
+ const missing = use.source.endSeconds - use.source.startSeconds - covered;
307
+ if (missing > 1e-3) findings.push({ code: "USED_RANGE_COVERAGE_GAP", severity: source.durationSeconds === null ? "info" : "warning", message: `${Math.round(missing * 1e3) / 1e3}s of this source use lack declared continuous ${audioOnly ? "audio" : "visual/motion"} inspection. Review relevant content when needed; snapshots do not establish interval coverage. This is not a requirement to inspect every unused source.`, sourceSha256: source.sha256, useIds: [use.id], interval: use.source });
308
+ }
309
+ let repeatedPairs = 0;
310
+ for (let i = 0; i < validUses.length; i++) for (let j = i + 1; j < validUses.length; j++) {
311
+ const a = validUses[i], b = validUses[j];
312
+ if (a.sourceSha256 !== b.sourceSha256) continue;
313
+ const startSeconds = Math.max(a.source.startSeconds, b.source.startSeconds), endSeconds = Math.min(a.source.endSeconds, b.source.endSeconds);
314
+ if (endSeconds - startSeconds <= 1e-3) continue;
315
+ repeatedPairs++;
316
+ if (repeatedPairs <= 200) findings.push({ code: b.reuseReason ? "SOURCE_REUSE_EXPLAINED" : "SOURCE_REUSE_REVIEW", severity: b.reuseReason ? "info" : "warning", message: b.reuseReason ? `Declared reuse: ${b.reuseReason}` : "These uses overlap the same source interval. Review whether the callback, comparison or repeated action serves the edit.", sourceSha256: a.sourceSha256, useIds: [a.id, b.id], interval: { startSeconds, endSeconds } });
317
+ }
318
+ if (repeatedPairs > 200) findings.push({ code: "REUSE_DETAILS_LIMIT", severity: "info", message: `${repeatedPairs} repeated pairs detected; only the first 200 are detailed.` });
319
+ const coverage = [...sources.values()].map((source) => {
320
+ const byModality = ["visual", "motion", "audio"].map((modality) => {
321
+ const observations = plan.inspections.filter((i) => i.sourceSha256 === source.sha256 && i.modality === modality);
322
+ const intervals = mergeProductionIntervals(observations.flatMap((i) => i.intervals ?? []).filter((r) => inBounds(r.endSeconds, source)));
323
+ const seconds = intervals.reduce((sum, i) => sum + i.endSeconds - i.startSeconds, 0);
324
+ return { modality, intervals, seconds, fraction: !source.durationSeconds ? null : seconds / source.durationSeconds, sampledSeconds: [...new Set(observations.flatMap((i) => i.sampledSeconds ?? []))].sort((a, b) => a - b) };
325
+ });
326
+ const sourceUses = validUses.filter((use) => use.sourceSha256 === source.sha256);
327
+ if (!plan.inspections.some((i) => i.sourceSha256 === source.sha256)) findings.push({ code: sourceUses.length ? "USED_SOURCE_UNINSPECTED" : "SOURCE_UNINSPECTED", severity: sourceUses.length ? "warning" : "info", message: sourceUses.length ? "Used source has no declared content inspection; metadata is not content review." : "Source has no declared content inspection. Using every source is not required.", sourceSha256: source.sha256 });
328
+ if (source.durationSeconds === null) findings.push({ code: "DURATION_UNKNOWN", severity: "info", message: "Duration unknown; interval bounds and coverage fraction cannot be verified for this source.", sourceSha256: source.sha256 });
329
+ return { sourceSha256: source.sha256, durationSeconds: source.durationSeconds, uses: sourceUses.length, byModality };
330
+ });
331
+ return { schemaVersion: 1, kind: "editorbrain.production-plan-check", valid: !findings.some((f) => f.severity === "error"), inventoryComplete, findings, coverage, repeatedPairs, sourceFilesReverified: false, quality: "not_assessed", observations: "agent_declared" };
332
+ }
333
+
33
334
  // packages/core/src/index.ts
34
335
  var SCHEMA_VERSION = 1;
35
336
  var MAX_DOCUMENT_BYTES = 128 * 1024;
@@ -41,15 +342,15 @@ var ValidationError = class extends Error {
41
342
  }
42
343
  issues;
43
344
  };
44
- var identifier = /^[a-z0-9][a-z0-9_-]{0,79}$/;
345
+ var identifier2 = /^[a-z0-9][a-z0-9_-]{0,79}$/;
45
346
  var tokenName = /^[a-z][a-z0-9_.-]{0,79}$/;
46
- var own = (value, key) => Object.prototype.hasOwnProperty.call(value, key);
47
- var object = (value) => !!value && typeof value === "object" && !Array.isArray(value);
48
- var nonempty = (value) => typeof value === "string" && value.trim().length > 0;
347
+ var own2 = (value, key) => Object.prototype.hasOwnProperty.call(value, key);
348
+ var object2 = (value) => !!value && typeof value === "object" && !Array.isArray(value);
349
+ var nonempty2 = (value) => typeof value === "string" && value.trim().length > 0;
49
350
  var finite = (value) => typeof value === "number" && Number.isFinite(value);
50
351
  var tokenValue = (value) => typeof value === "string" || typeof value === "boolean" || finite(value) || Array.isArray(value) && value.every((v) => typeof v === "string");
51
352
  function assertIdentifier(value) {
52
- if (!identifier.test(value)) throw new Error(`Invalid identifier: ${value}. Use lowercase letters, numbers, _ or - (max 80).`);
353
+ if (!identifier2.test(value)) throw new Error(`Invalid identifier: ${value}. Use lowercase letters, numbers, _ or - (max 80).`);
53
354
  return value;
54
355
  }
55
356
  function safeDocumentPath(path) {
@@ -61,13 +362,13 @@ function safeDocumentPath(path) {
61
362
  return path;
62
363
  }
63
364
  function checkRules(value, add) {
64
- if (!object(value)) {
365
+ if (!object2(value)) {
65
366
  add("tokens must be an object");
66
367
  return;
67
368
  }
68
369
  for (const [key, rule] of Object.entries(value)) {
69
370
  if (!tokenName.test(key) || ["__proto__", "constructor", "prototype"].includes(key)) add(`Invalid semantic token ${key}`);
70
- if (!object(rule) || !tokenValue(rule.value) || !["required", "preferred", "example"].includes(String(rule.strength))) add(`Token ${key} requires value and strength required/preferred/example`);
371
+ if (!object2(rule) || !tokenValue(rule.value) || !["required", "preferred", "example"].includes(String(rule.strength))) add(`Token ${key} requires value and strength required/preferred/example`);
71
372
  }
72
373
  }
73
374
  function checkEvidence(value, add) {
@@ -76,7 +377,7 @@ function checkEvidence(value, add) {
76
377
  return;
77
378
  }
78
379
  for (const ref of value) {
79
- if (!object(ref) || !nonempty(ref.videoId) || !["reference_only", "licensed_asset", "owned_asset"].includes(String(ref.relationship))) add("Evidence must include videoId and explicit relationship");
380
+ if (!object2(ref) || !nonempty2(ref.videoId) || !["reference_only", "licensed_asset", "owned_asset"].includes(String(ref.relationship))) add("Evidence must include videoId and explicit relationship");
80
381
  else {
81
382
  if (ref.timestampSeconds !== void 0 && (!finite(ref.timestampSeconds) || ref.timestampSeconds < 0)) add("Evidence timestamp must be nonnegative seconds");
82
383
  if (ref.reviewStatus !== void 0 && !["unreviewed", "agent_reviewed", "human_verified"].includes(String(ref.reviewStatus))) add("Invalid evidence reviewStatus");
@@ -105,7 +406,7 @@ function validateDocument(path, content) {
105
406
  add("Invalid JSON");
106
407
  return { valid: false, issues };
107
408
  }
108
- if (!object(doc)) {
409
+ if (!object2(doc)) {
109
410
  add("JSON document must be an object");
110
411
  return { valid: false, issues };
111
412
  }
@@ -119,38 +420,39 @@ function validateDocument(path, content) {
119
420
  const isMedia = parts[0] === "videos" && parts.length === 3 && parts[2] === "media.json";
120
421
  if (isBrand || isStyle || isProject || isOutput) {
121
422
  const expected = isOutput ? parts[2].slice(0, -5) : parts[isProject ? 1 : 2];
122
- if (!identifier.test(expected)) add("Invalid resource identifier in path");
423
+ if (!identifier2.test(expected)) add("Invalid resource identifier in path");
123
424
  if (doc.id !== void 0 && doc.id !== expected) add(`Document id must match path: ${expected}`);
124
425
  }
426
+ if (isStyle && parts[3] === "resources.json") for (const issue of validateStyleResources(doc, parts[2]).issues) add(issue.message);
125
427
  if (isBrand && parts[3] === "tokens.json" || isStyle && parts[3] === "style.json") {
126
- if (!nonempty(doc.id)) add("id is required");
428
+ if (!nonempty2(doc.id)) add("id is required");
127
429
  if (isBrand || doc.tokens !== void 0) checkRules(doc.tokens, add);
128
430
  if (isStyle) {
129
- if (!nonempty(doc.name)) add("Style name is required");
431
+ if (!nonempty2(doc.name)) add("Style name is required");
130
432
  if (doc.references !== void 0) checkEvidence(doc.references, add);
131
- if (doc.components !== void 0 && (!Array.isArray(doc.components) || doc.components.some((c) => !object(c) || !nonempty(c.id) || !["concept", "implemented", "transfer_tested"].includes(String(c.status))))) add("Components need id and explicit implementation status");
433
+ 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");
132
434
  }
133
435
  }
134
436
  if (isProject) {
135
- for (const key of ["id", "brand", "style", "outputProfile"]) if (typeof doc[key] !== "string" || !identifier.test(doc[key])) add(`${key} must be a valid identifier`);
136
- if (!object(doc.intent) || !nonempty(doc.intent.topic) || !nonempty(doc.intent.goal)) add("intent requires topic and goal");
437
+ for (const key of ["id", "brand", "style", "outputProfile"]) if (typeof doc[key] !== "string" || !identifier2.test(doc[key])) add(`${key} must be a valid identifier`);
438
+ if (!object2(doc.intent) || !nonempty2(doc.intent.topic) || !nonempty2(doc.intent.goal)) add("intent requires topic and goal");
137
439
  if (doc.reviewMode !== void 0 && !["direct", "directions", "sample"].includes(doc.reviewMode)) add("reviewMode must be direct, directions or sample");
138
440
  if (doc.script !== void 0 && typeof doc.script !== "string") add("script must be text");
139
441
  if (doc.overrides !== void 0) {
140
- if (!object(doc.overrides)) add("overrides must be an object");
442
+ if (!object2(doc.overrides)) add("overrides must be an object");
141
443
  else for (const [key, rule] of Object.entries(doc.overrides)) {
142
444
  if (!tokenName.test(key) || ["__proto__", "constructor", "prototype"].includes(key)) add(`Invalid override token ${key}`);
143
- if (!object(rule) || !tokenValue(rule.value) || !nonempty(rule.reason) || rule.overrideRequired !== void 0 && typeof rule.overrideRequired !== "boolean") add(`Override ${key} requires value and a reason; overrideRequired must be boolean`);
445
+ 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`);
144
446
  }
145
447
  }
146
448
  }
147
449
  if (isMedia) {
148
- if (!identifier.test(parts[1])) add("Invalid project identifier in media path");
450
+ if (!identifier2.test(parts[1])) add("Invalid project identifier in media path");
149
451
  if (!Array.isArray(doc.assets)) add("Production media requires an assets array");
150
452
  else {
151
453
  const ids = /* @__PURE__ */ new Set();
152
454
  for (const asset of doc.assets) {
153
- if (!object(asset) || typeof asset.id !== "string" || !/^[a-f0-9]{64}$/.test(asset.id) || !nonempty(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) {
455
+ 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) {
154
456
  add("Production assets require a SHA-256 id, name, media mimeType and positive integer bytes");
155
457
  continue;
156
458
  }
@@ -160,11 +462,11 @@ function validateDocument(path, content) {
160
462
  }
161
463
  }
162
464
  if (isOutput) {
163
- if (!nonempty(doc.id)) add("id is required");
465
+ if (!nonempty2(doc.id)) add("id is required");
164
466
  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`);
165
467
  if (!finite(doc.fps) || doc.fps <= 0 || doc.fps > 240) add("fps must be between 0 and 240");
166
468
  if (doc.safeAreas !== void 0) {
167
- if (!object(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");
469
+ 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");
168
470
  }
169
471
  }
170
472
  return { valid: issues.length === 0, issues };
@@ -185,7 +487,7 @@ function projectSources(project) {
185
487
  ];
186
488
  }
187
489
  function optionalProjectSources(project) {
188
- 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)];
490
+ 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`];
189
491
  }
190
492
  function validateWorkspace(files) {
191
493
  files = canonicalFiles(files);
@@ -194,31 +496,31 @@ function validateWorkspace(files) {
194
496
  for (const [path, content] of Object.entries(files)) {
195
497
  if (!/^videos\/[^/]+\/project\.json$/.test(path) || !validateDocument(path, content).valid) continue;
196
498
  const project = JSON.parse(content);
197
- for (const required of projectSources(project)) if (!own(files, required)) issues.push({ path, message: `Missing required source ${required}` });
499
+ for (const required of projectSources(project)) if (!own2(files, required)) issues.push({ path, message: `Missing required source ${required}` });
198
500
  }
199
501
  return { valid: issues.length === 0, issues };
200
502
  }
201
503
  function stableJson(value) {
202
- const sort = (item) => Array.isArray(item) ? item.map(sort) : object(item) ? Object.fromEntries(Object.keys(item).sort().map((key) => [key, sort(item[key])])) : item;
504
+ const sort = (item) => Array.isArray(item) ? item.map(sort) : object2(item) ? Object.fromEntries(Object.keys(item).sort().map((key) => [key, sort(item[key])])) : item;
203
505
  return JSON.stringify(sort(value), null, 2) + "\n";
204
506
  }
205
507
  async function sha256(content) {
206
508
  const bytes = typeof content === "string" ? new TextEncoder().encode(content) : content;
207
- const digest = await crypto.subtle.digest("SHA-256", new Uint8Array(bytes));
208
- return Array.from(new Uint8Array(digest), (b) => b.toString(16).padStart(2, "0")).join("");
509
+ const digest2 = await crypto.subtle.digest("SHA-256", new Uint8Array(bytes));
510
+ return Array.from(new Uint8Array(digest2), (b) => b.toString(16).padStart(2, "0")).join("");
209
511
  }
210
512
  async function resolveProject(files, projectId) {
211
513
  files = canonicalFiles(files);
212
514
  assertIdentifier(projectId);
213
515
  const path = `videos/${projectId}/project.json`;
214
- if (!own(files, path)) throw new ValidationError([{ path, message: "Project does not exist" }]);
516
+ if (!own2(files, path)) throw new ValidationError([{ path, message: "Project does not exist" }]);
215
517
  const basic = validateDocument(path, files[path]);
216
518
  if (!basic.valid) throw new ValidationError(basic.issues);
217
519
  const project = JSON.parse(files[path]);
218
520
  const requiredPaths = projectSources(project);
219
521
  const optionalPaths = optionalProjectSources(project);
220
- const sourcePaths = [...requiredPaths, ...optionalPaths.filter((p) => own(files, p))];
221
- const relevant = Object.fromEntries(sourcePaths.filter((p) => own(files, p)).map((p) => [p, files[p]]));
522
+ const sourcePaths = [...requiredPaths, ...optionalPaths.filter((p) => own2(files, p))];
523
+ const relevant = Object.fromEntries(sourcePaths.filter((p) => own2(files, p)).map((p) => [p, files[p]]));
222
524
  const validation = validateWorkspace(relevant);
223
525
  if (!validation.valid) throw new ValidationError(validation.issues);
224
526
  const [brandPath, tokensPath, languagePath, assetsPath, styleMdPath, stylePath, examplesPath, outputPath, , briefPath, directionPath] = requiredPaths;
@@ -259,11 +561,12 @@ async function resolveProject(files, projectId) {
259
561
  output: output2,
260
562
  tokens,
261
563
  tokenSources,
262
- editorial: { brand: files[brandPath], style: files[styleMdPath], brief: files[briefPath], direction: files[directionPath], ...Object.fromEntries(["design", "effects", "music"].flatMap((key, index) => own(files, optionalPaths[index]) ? [[key, files[optionalPaths[index]]]] : [])) },
564
+ 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]]]] : [])) },
263
565
  language: JSON.parse(files[languagePath]),
264
566
  brandAssets: JSON.parse(files[assetsPath]),
265
567
  examples: JSON.parse(files[examplesPath]),
266
- ...own(files, optionalPaths[3]) ? { media: JSON.parse(files[optionalPaths[3]]) } : {},
568
+ ...own2(files, optionalPaths[3]) ? { media: JSON.parse(files[optionalPaths[3]]) } : {},
569
+ ...own2(files, optionalPaths[7]) ? { styleResources: JSON.parse(files[optionalPaths[7]]) } : {},
267
570
  memory: resolveMemory(files, project),
268
571
  evidence: style.references ?? [],
269
572
  warnings,
@@ -282,6 +585,10 @@ ${content}` : "";
282
585
 
283
586
  \`\`\`json
284
587
  ${stableJson(resolved.media)}\`\`\``) : "";
588
+ 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.
589
+
590
+ \`\`\`json
591
+ ${stableJson(resolved.styleResources)}\`\`\``) : "";
285
592
  if (adapter === "neutral") return { ...common, "brief.md": `# ${resolved.project.id}
286
593
 
287
594
  ${disclaimer}
@@ -298,7 +605,7 @@ ${resolved.editorial.brand}
298
605
 
299
606
  ## Style
300
607
 
301
- ${resolved.editorial.style}${section("Design", resolved.editorial.design)}${section("Effects", resolved.editorial.effects)}${section("Music and sound", resolved.editorial.music)}${mediaSection}
608
+ ${resolved.editorial.style}${section("Design", resolved.editorial.design)}${section("Effects", resolved.editorial.effects)}${section("Music and sound", resolved.editorial.music)}${mediaSection}${resourcesSection}
302
609
 
303
610
  Exact tokens, output settings and evidence are in resolved.json.
304
611
  ` };
@@ -326,13 +633,13 @@ ${resolved.editorial.direction}
326
633
 
327
634
  ## Style intent
328
635
 
329
- ${resolved.editorial.style}${section("Effects", resolved.editorial.effects)}${section("Music and sound", resolved.editorial.music)}${mediaSection}
636
+ ${resolved.editorial.style}${section("Effects", resolved.editorial.effects)}${section("Music and sound", resolved.editorial.music)}${mediaSection}${resourcesSection}
330
637
 
331
638
  ## Delivery
332
639
 
333
640
  ${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.
334
641
  ` };
335
- 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."] }) };
642
+ 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."] }) };
336
643
  throw new Error(`Unsupported adapter: ${adapter}`);
337
644
  }
338
645
  function createWorkspace(options = {}) {
@@ -672,11 +979,11 @@ async function pushWorkspace(client, root, files, options = {}) {
672
979
  for (const [path, content] of Object.entries(files)) {
673
980
  const validation = validateDocument(path, content);
674
981
  if (!validation.valid) throw new Error(validation.issues.map((x) => `${x.path}: ${x.message}`).join("\n"));
675
- const digest = await sha256(content);
982
+ const digest2 = await sha256(content);
676
983
  const existing = remote.get(path);
677
984
  const base = state.documents[path];
678
- if (existing?.sha256 === digest) {
679
- state.documents[path] = { revision: existing.revision, sha256: digest };
985
+ if (existing?.sha256 === digest2) {
986
+ state.documents[path] = { revision: existing.revision, sha256: digest2 };
680
987
  unchanged++;
681
988
  continue;
682
989
  }
@@ -697,8 +1004,8 @@ ${conflicts.join("\n")}`, "Run push --dry-run and reconcile the reported paths."
697
1004
  for (const item of pending) {
698
1005
  const result = await client.put(item.path, item.content, item.expectedRevision);
699
1006
  checkRemote(result);
700
- const digest = await sha256(item.content);
701
- if (result.path !== item.path || result.sha256 !== digest) throw new Error("Server returned inconsistent write metadata");
1007
+ const digest2 = await sha256(item.content);
1008
+ if (result.path !== item.path || result.sha256 !== digest2) throw new Error("Server returned inconsistent write metadata");
702
1009
  state.documents[item.path] = { revision: result.revision, sha256: result.sha256 };
703
1010
  uploaded.push(item.path);
704
1011
  await saveSyncState(root, state);
@@ -716,14 +1023,14 @@ async function pullWorkspace(client, root, local, options = {}) {
716
1023
  for (const summary of summaries) {
717
1024
  checkRemote(summary);
718
1025
  const content = local[summary.path];
719
- const digest = content === void 0 ? void 0 : await sha256(content);
1026
+ const digest2 = content === void 0 ? void 0 : await sha256(content);
720
1027
  const base = state.documents[summary.path];
721
- if (digest === summary.sha256) {
1028
+ if (digest2 === summary.sha256) {
722
1029
  state.documents[summary.path] = { revision: summary.revision, sha256: summary.sha256 };
723
1030
  unchanged++;
724
1031
  continue;
725
1032
  }
726
- if (content !== void 0 && (!base || digest !== base.sha256)) {
1033
+ if (content !== void 0 && (!base || digest2 !== base.sha256)) {
727
1034
  conflicts.push(summary.path);
728
1035
  continue;
729
1036
  }
@@ -809,7 +1116,7 @@ import { createHash } from "node:crypto";
809
1116
  import { constants as constants2 } from "node:fs";
810
1117
  import { lstat as lstat2, open as open2, readdir as readdir2, rename as rename2 } from "node:fs/promises";
811
1118
  import { fileURLToPath } from "node:url";
812
- var previousSkillHashes = { "SKILL.md": ["9df2c89deeec30ddf556c359027efe1d90e6b4e9c08717cd8827e210b38a187f", "67b157cb45cae3621e6f2c43235c1d69907ad6abd45ab6b52a3c65f236732db9", "1e2fff65a1b303b18b2d65156bef6eb2439a19d77ba2a25b37bcb1819ff009e3", "dacce1c4127f4281df6f7e1681e243444bb6f8a8e74b829e52ee0108c6672348"], "AGENTS.md": ["4f3ef860ade3a31aa7c6a6aab5cf6ec2a700850bf19ea2320324c12c2e0fbf5b", "1b07de01698a0040b47ea0eddb1b017f70a69e81acd0bf9710647ca5e02f5125", "b5ee3439ff974c668755816149fab76899d7ad9266f63978b30fbc9b7d2cd640", "bacedb7bdedab5abe75c9601717cfdb05a96208fa2e2a42af878e2dfd2ae4251"], "references/cli-mcp.md": ["5d3a79343fbd76cdae5bdbe12fb9c944d5dff4f489ac6534fd96e0cb8630b988", "8e011c31368cef2117a28ee972b39aab9535eeb0f601a4a0f9d6fc019b22c1c2", "666082653efc000f8a7cc5f1836c80bbdda5f0f57cdbbdcb393f2b84aeb93a01", "78b737796a8685b1135e0acb91b620c51845b62e31dba04eb51dd9dd38b41008", "8b2e01bfd937237919f5ade794eed01dc6e32b556ede9de3a0ccc28cbdd988d1"], "references/production-and-review.md": ["055c6df9e0f167be148b0a5c0bba5b43e108eb1f58f478d88b3536c6589ac7b3"], "references/workspace-files.md": ["e72af2871f42cd776be1bfff8009ed5562780e7f8a65b12c27887f3880631f81", "00780c64bd0244c6afb18d786b7512b43e315c376e8f88c67d6ba53d91275ccf", "97053b092e0ff36e3cfb764095fcb4a4fca94e722567e774b53bc118279f40e5", "70fc660e7ed3d70c16c33c62c8e0399b99c3251808f093ef3d4ea5e8d39e4c8a"] };
1119
+ var previousSkillHashes = { "SKILL.md": ["9df2c89deeec30ddf556c359027efe1d90e6b4e9c08717cd8827e210b38a187f", "67b157cb45cae3621e6f2c43235c1d69907ad6abd45ab6b52a3c65f236732db9", "1e2fff65a1b303b18b2d65156bef6eb2439a19d77ba2a25b37bcb1819ff009e3", "dacce1c4127f4281df6f7e1681e243444bb6f8a8e74b829e52ee0108c6672348", "09740ab4b275eada65b5040ae6000104ff39094f534a2784e67feb2afc2a59b3", "f18269ad5504fff8a4fc522d463483261f5041e56cb7f1081f9614bc67e0d96f"], "AGENTS.md": ["4f3ef860ade3a31aa7c6a6aab5cf6ec2a700850bf19ea2320324c12c2e0fbf5b", "1b07de01698a0040b47ea0eddb1b017f70a69e81acd0bf9710647ca5e02f5125", "b5ee3439ff974c668755816149fab76899d7ad9266f63978b30fbc9b7d2cd640", "bacedb7bdedab5abe75c9601717cfdb05a96208fa2e2a42af878e2dfd2ae4251", "ea3b473179857ccc07e9e4f21a6ede9e0906a09e495f4a051069d15aee1556cd", "faa2bba99ae3023be7f588d02177a4abf583d3a8a65f617b8cff7a3ceed09dfc"], "references/cli-mcp.md": ["5d3a79343fbd76cdae5bdbe12fb9c944d5dff4f489ac6534fd96e0cb8630b988", "8e011c31368cef2117a28ee972b39aab9535eeb0f601a4a0f9d6fc019b22c1c2", "666082653efc000f8a7cc5f1836c80bbdda5f0f57cdbbdcb393f2b84aeb93a01", "78b737796a8685b1135e0acb91b620c51845b62e31dba04eb51dd9dd38b41008", "8b2e01bfd937237919f5ade794eed01dc6e32b556ede9de3a0ccc28cbdd988d1", "ca88b2816a795a5ebe834074b178469367634b80ad837e3697182ea585af1c05", "490daf154b428b536d0a6f590030c854b2fc8ca6adb995c6ecd5a162f85e471e"], "references/production-and-review.md": ["055c6df9e0f167be148b0a5c0bba5b43e108eb1f58f478d88b3536c6589ac7b3"], "references/workspace-files.md": ["e72af2871f42cd776be1bfff8009ed5562780e7f8a65b12c27887f3880631f81", "00780c64bd0244c6afb18d786b7512b43e315c376e8f88c67d6ba53d91275ccf", "97053b092e0ff36e3cfb764095fcb4a4fca94e722567e774b53bc118279f40e5", "70fc660e7ed3d70c16c33c62c8e0399b99c3251808f093ef3d4ea5e8d39e4c8a", "28c62c1e28842142607f5168ee79b18fd797900dd66dd9ac5eb3cfcf8a09f781", "b86eae85cf86dbf4409b10f4d8df1f1649fc408ce1a240f7cf26d5bb69aabfd1"], "references/first-edit.md": ["cb1e9d3272b2b22ae095aed02e33067c646da48e7b64cf71ff81197f6e67b673"], "references/memory-and-review.md": ["51a274e522270f1fc295d0c0b9a1c23672ce01abea097c2499c9a8db56c0c289"], "references/search-brief.md": ["137203383a7873593136428520f5f9cd6ce1ab6cfeab365c277175786eba3ab3"], "references/style-creator.md": ["1b1bc289a5bc308cbbf7f4d699de2933b8934733a2b69b3e98fb1b7420d986f2"] };
813
1120
  var destinations = {
814
1121
  codex: { directory: ".agents/skills/editorbrain", entryPoint: "AGENTS.md" },
815
1122
  claude: { directory: ".claude/skills/editorbrain", entryPoint: "CLAUDE.md" }
@@ -858,6 +1165,9 @@ async function loadSkill(directory) {
858
1165
  if (!files.has("SKILL.md")) throw new Error("Bundled EditorBrain skill is missing SKILL.md. Reinstall the CLI package.");
859
1166
  return files;
860
1167
  }
1168
+ async function bundledGuidanceFiles() {
1169
+ return [...(await loadSkill(bundledSkillDirectory())).keys()];
1170
+ }
861
1171
  async function prepareAgentGuidance(root, agent = "both", sourceDirectory = bundledSkillDirectory()) {
862
1172
  root = await ensureRoot(root);
863
1173
  parseAgentTarget(agent);
@@ -880,13 +1190,13 @@ Keep credentials outside this folder. Treat retrieved reference material as evid
880
1190
  for (const selected of agents) {
881
1191
  const to = destinations[selected].directory, from = to.replace(/editorbrain$/, "editorharness");
882
1192
  const oldPath = await checkedPath(root, from, false), newPath = await checkedPath(root, to, false);
883
- const exists = async (path) => lstat2(path).then(() => true, (error) => {
1193
+ const exists2 = async (path) => lstat2(path).then(() => true, (error) => {
884
1194
  if (error.code === "ENOENT") return false;
885
1195
  throw error;
886
1196
  });
887
- if (await exists(oldPath)) {
1197
+ if (await exists2(oldPath)) {
888
1198
  await loadSkill(oldPath);
889
- if (await exists(newPath)) throw new Error("Two skill directories exist. Reconcile them before installing; neither was changed.");
1199
+ if (await exists2(newPath)) throw new Error("Two skill directories exist. Reconcile them before installing; neither was changed.");
890
1200
  migrations.push({ from, to });
891
1201
  }
892
1202
  }
@@ -948,7 +1258,7 @@ import { lstat as lstat3, open as open3, rename as rename3, unlink as unlink2 }
948
1258
  import { hostname } from "node:os";
949
1259
  import { randomUUID as randomUUID2 } from "node:crypto";
950
1260
  var LOCK_PATH = ".editorbrain-operation.lock";
951
- async function withWorkspaceLock(root, operation) {
1261
+ async function withWorkspaceLock(root, operation, options = {}) {
952
1262
  const legacyLock = await checkedPath(root, ".editorharness-operation.lock", false);
953
1263
  let legacy;
954
1264
  try {
@@ -972,12 +1282,12 @@ async function withWorkspaceLock(root, operation) {
972
1282
  await handle.close();
973
1283
  const oldRoot = await checkedPath(root, ".editorharness", false);
974
1284
  const newRoot = await checkedPath(root, ".editorbrain", false);
975
- const exists = async (path2) => lstat3(path2).then(() => true, (error) => {
1285
+ const exists2 = async (path2) => lstat3(path2).then(() => true, (error) => {
976
1286
  if (error.code === "ENOENT") return false;
977
1287
  throw error;
978
1288
  });
979
- if (await exists(oldRoot)) {
980
- if (await exists(newRoot)) throw new CliError("WORKSPACE_PATH_CONFLICT", "This folder contains two versions of the workspace directory.", "Reconcile the directories before continuing; neither was changed.");
1289
+ if (options.migrateLegacy !== false && await exists2(oldRoot)) {
1290
+ 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.");
981
1291
  await rename3(oldRoot, newRoot);
982
1292
  }
983
1293
  return await operation();
@@ -999,7 +1309,6 @@ async function cliVersion() {
999
1309
  const metadata = JSON.parse(await readFile(new URL("../package.json", import.meta.url), "utf8"));
1000
1310
  return metadata.version;
1001
1311
  }
1002
- var guidanceFiles = ["SKILL.md", "AGENTS.md", "references/cli-mcp.md", "references/production-and-review.md", "references/workspace-files.md"];
1003
1312
  async function diagnose(options, fetcher = fetch) {
1004
1313
  const checks = [];
1005
1314
  let root;
@@ -1027,7 +1336,7 @@ async function diagnose(options, fetcher = fetch) {
1027
1336
  const directory = agent === "codex" ? ".agents/skills/editorbrain" : ".claude/skills/editorbrain";
1028
1337
  let different = false;
1029
1338
  try {
1030
- for (const file of guidanceFiles) {
1339
+ for (const file of await bundledGuidanceFiles()) {
1031
1340
  const current = await readText(root, `${directory}/${file}`, false);
1032
1341
  const bundled = await readFile(resolve2(bundledSkillDirectory(), file), "utf8");
1033
1342
  if (current !== bundled) different = true;
@@ -1099,7 +1408,7 @@ async function configureMcp(agent, directory, url, noBrowser, runner = runHost)
1099
1408
  const version = await runner(agent, ["--version"], directory);
1100
1409
  if (version.code !== 0) throw new CliError("AGENT_UNAVAILABLE", `${agent} CLI is unavailable.`, "Install the official agent CLI, or use setup --skip-mcp.");
1101
1410
  const inspect = await runner(agent, agent === "codex" ? ["mcp", "list", "--json"] : ["mcp", "get", "editorbrain"], directory);
1102
- let exists = false;
1411
+ let exists2 = false;
1103
1412
  if (agent === "codex") {
1104
1413
  if (inspect.code !== 0) throw new CliError("MCP_INSPECTION_FAILED", "Could not inspect Codex MCP configuration.", "Inspect it manually; setup did not overwrite it.");
1105
1414
  let list;
@@ -1109,12 +1418,12 @@ async function configureMcp(agent, directory, url, noBrowser, runner = runHost)
1109
1418
  throw new CliError("MCP_INSPECTION_FAILED", "Codex returned an unsupported configuration format.", "Inspect MCP configuration manually.");
1110
1419
  }
1111
1420
  if (!Array.isArray(list)) throw new CliError("MCP_INSPECTION_FAILED", "Unsupported MCP configuration response.", "Inspect MCP configuration manually.");
1112
- exists = list.some((item) => item?.name === "editorbrain");
1421
+ exists2 = list.some((item) => item?.name === "editorbrain");
1113
1422
  } else {
1114
- exists = inspect.code === 0;
1115
- if (!exists && !/^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.");
1423
+ exists2 = inspect.code === 0;
1424
+ 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.");
1116
1425
  }
1117
- if (exists) return { agent, status: "preserved", next: "An existing editorbrain server was preserved. Check its endpoint and authentication, then " + next, hostVerified: false };
1426
+ if (exists2) return { agent, status: "preserved", next: "An existing editorbrain server was preserved. Check its endpoint and authentication, then " + next, hostVerified: false };
1118
1427
  const added = await runner(agent, args, directory, true);
1119
1428
  return { agent, status: added.code === 0 ? "registered" : "action_required", next, hostVerified: false };
1120
1429
  }
@@ -1169,10 +1478,10 @@ async function setup(options, runner = runHost) {
1169
1478
  }
1170
1479
 
1171
1480
  // packages/cli/src/index.ts
1172
- import { chmod, lstat as lstat4, mkdir as mkdir2, open as open4 } from "node:fs/promises";
1173
- import { constants as constants4 } from "node:fs";
1481
+ import { chmod, lstat as lstat7, mkdir as mkdir5, open as open7 } from "node:fs/promises";
1482
+ import { constants as constants7 } from "node:fs";
1174
1483
  import { homedir } from "node:os";
1175
- import { basename, dirname as dirname2, extname, join as join2, resolve as resolve4 } from "node:path";
1484
+ import { basename as basename2, dirname as dirname6, extname as extname3, join as join3, resolve as resolve6 } from "node:path";
1176
1485
 
1177
1486
  // packages/cli/src/device-auth.ts
1178
1487
  import { spawn as spawn2 } from "node:child_process";
@@ -1193,12 +1502,12 @@ function browserCommand(url, platform = process.platform) {
1193
1502
  }
1194
1503
  async function openSystemBrowser(url) {
1195
1504
  const { command, args } = browserCommand(url);
1196
- await new Promise((resolve5, reject) => {
1505
+ await new Promise((resolve7, reject) => {
1197
1506
  const child = spawn2(command, args, { shell: false, stdio: "ignore", detached: true, windowsHide: true });
1198
1507
  child.once("error", reject);
1199
1508
  child.once("spawn", () => {
1200
1509
  child.unref();
1201
- resolve5();
1510
+ resolve7();
1202
1511
  });
1203
1512
  });
1204
1513
  }
@@ -1211,7 +1520,7 @@ function expired() {
1211
1520
  function invalid() {
1212
1521
  return new DeviceLoginError("invalid_response", "The authorization server returned an invalid response. No credential was saved.");
1213
1522
  }
1214
- function record(value) {
1523
+ function record2(value) {
1215
1524
  return !!value && typeof value === "object" && !Array.isArray(value);
1216
1525
  }
1217
1526
  function abortCheck(signal) {
@@ -1259,11 +1568,11 @@ async function responseJson(response) {
1259
1568
  }
1260
1569
  }
1261
1570
  function sessionFrom(value) {
1262
- if (!record(value) || typeof value.deviceCode !== "string" || !/^[a-zA-Z0-9._~-]{16,1024}$/.test(value.deviceCode) || typeof value.userCode !== "string" || !/^[A-Z0-9-]{4,32}$/.test(value.userCode) || typeof value.verificationUri !== "string" || !Number.isInteger(value.expiresIn) || Number(value.expiresIn) < 1 || Number(value.expiresIn) > 3600 || !Number.isInteger(value.interval) || Number(value.interval) < 1 || Number(value.interval) > 60) throw invalid();
1571
+ if (!record2(value) || typeof value.deviceCode !== "string" || !/^[a-zA-Z0-9._~-]{16,1024}$/.test(value.deviceCode) || typeof value.userCode !== "string" || !/^[A-Z0-9-]{4,32}$/.test(value.userCode) || typeof value.verificationUri !== "string" || !Number.isInteger(value.expiresIn) || Number(value.expiresIn) < 1 || Number(value.expiresIn) > 3600 || !Number.isInteger(value.interval) || Number(value.interval) < 1 || Number(value.interval) > 60) throw invalid();
1263
1572
  return { deviceCode: value.deviceCode, userCode: value.userCode, verificationUri: verificationUrl(value.verificationUri, value.userCode, value.deviceCode), expiresIn: Number(value.expiresIn), interval: Number(value.interval) };
1264
1573
  }
1265
1574
  function authorizationFrom(value) {
1266
- if (!record(value) || typeof value.apiKey !== "string" || !/^eh_[a-f0-9]{64}$/.test(value.apiKey) || value.role !== "editor" || !record(value.workspace) || typeof value.workspace.id !== "string" || !/^[a-zA-Z0-9_-]{1,100}$/.test(value.workspace.id) || typeof value.workspace.name !== "string" || !value.workspace.name.trim() || value.workspace.name.length > 100) throw invalid();
1575
+ if (!record2(value) || typeof value.apiKey !== "string" || !/^eh_[a-f0-9]{64}$/.test(value.apiKey) || value.role !== "editor" || !record2(value.workspace) || typeof value.workspace.id !== "string" || !/^[a-zA-Z0-9_-]{1,100}$/.test(value.workspace.id) || typeof value.workspace.name !== "string" || !value.workspace.name.trim() || value.workspace.name.length > 100) throw invalid();
1267
1576
  return { apiKey: value.apiKey, workspace: { id: value.workspace.id, name: value.workspace.name }, role: "editor" };
1268
1577
  }
1269
1578
  async function loginWithDevice(options, overrides = {}) {
@@ -1287,7 +1596,7 @@ async function loginWithDevice(options, overrides = {}) {
1287
1596
  throw new DeviceLoginError("network_error", "The authorization server could not be reached. No credential was saved.");
1288
1597
  }
1289
1598
  };
1290
- const parse = async (response) => {
1599
+ const parse2 = async (response) => {
1291
1600
  try {
1292
1601
  return await responseJson(response);
1293
1602
  } catch (error) {
@@ -1299,7 +1608,7 @@ async function loginWithDevice(options, overrides = {}) {
1299
1608
  };
1300
1609
  const started = await post("/v1/auth/device", {});
1301
1610
  if (started.status !== 200 && started.status !== 201) throw new DeviceLoginError("server_error", `Could not start browser authorization (HTTP ${started.status}).`);
1302
- const session = sessionFrom(await parse(started));
1611
+ const session = sessionFrom(await parse2(started));
1303
1612
  deadline = Math.min(deadline, runtime.now() + session.expiresIn * 1e3);
1304
1613
  const { deviceCode, ...challenge } = session;
1305
1614
  options.onChallenge?.(challenge);
@@ -1326,7 +1635,7 @@ async function loginWithDevice(options, overrides = {}) {
1326
1635
  if (runtime.now() >= deadline) throw expired();
1327
1636
  const response = await post("/v1/auth/device/token", { deviceCode });
1328
1637
  if (response.status === 200) {
1329
- const result = authorizationFrom(await parse(response));
1638
+ const result = authorizationFrom(await parse2(response));
1330
1639
  abortCheck(options.signal);
1331
1640
  return result;
1332
1641
  }
@@ -1374,6 +1683,902 @@ async function prepareDirectionContext(root, client, projectId) {
1374
1683
  };
1375
1684
  }
1376
1685
 
1686
+ // packages/cli/src/style-authoring.ts
1687
+ import { createHash as createHash2 } from "node:crypto";
1688
+ import { constants as constants4 } from "node:fs";
1689
+ import { lstat as lstat4, mkdir as mkdir2, open as open4, readdir as readdir3, rmdir, unlink as unlink3 } from "node:fs/promises";
1690
+ import { dirname as dirname2 } from "node:path";
1691
+ function styleId(id) {
1692
+ try {
1693
+ return assertIdentifier(id);
1694
+ } catch {
1695
+ 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>.");
1696
+ }
1697
+ }
1698
+ async function exists(path) {
1699
+ try {
1700
+ await lstat4(path);
1701
+ return true;
1702
+ } catch (error) {
1703
+ if (error.code === "ENOENT") return false;
1704
+ throw error;
1705
+ }
1706
+ }
1707
+ async function preflightCreate(root, id, files) {
1708
+ for (const prefix of [".editorbrain", ".editorharness"]) {
1709
+ const path = `${prefix}/styles/${id}`;
1710
+ if (await exists(await checkedPath(root, path, false))) {
1711
+ 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.");
1712
+ }
1713
+ }
1714
+ if (await exists(await checkedPath(root, ".editorharness", false))) {
1715
+ 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.");
1716
+ }
1717
+ for (const path of Object.keys(files)) {
1718
+ if (!path.startsWith(`.editorbrain/styles/${id}/`)) throw new Error("Style template escaped its destination");
1719
+ const target = await checkedPath(root, path);
1720
+ if (await exists(target)) throw new CliError("STYLE_EXISTS", `Refusing to replace existing file: ${path}`, "Choose a new style ID.");
1721
+ }
1722
+ }
1723
+ async function makeDirectories(root, path, created) {
1724
+ const segments = path.split("/");
1725
+ for (let i = 1; i <= segments.length; i++) {
1726
+ const relative2 = segments.slice(0, i).join("/");
1727
+ const target = await checkedPath(root, relative2, false);
1728
+ try {
1729
+ await mkdir2(target);
1730
+ const info = await lstat4(target);
1731
+ created.push({ path: relative2, dev: info.dev, ino: info.ino });
1732
+ } catch (error) {
1733
+ if (error.code !== "EEXIST") throw error;
1734
+ if (!(await lstat4(await checkedPath(root, relative2, false))).isDirectory()) throw new Error(`Not a directory: ${relative2}`);
1735
+ }
1736
+ }
1737
+ }
1738
+ async function cleanCreated(root, paths, directory) {
1739
+ for (const item of [...paths].reverse()) {
1740
+ try {
1741
+ const target = await checkedPath(root, item.path, false);
1742
+ const info = await lstat4(target);
1743
+ if (info.dev !== item.dev || info.ino !== item.ino || info.isSymbolicLink()) continue;
1744
+ if (directory) await rmdir(target);
1745
+ else await unlink3(target);
1746
+ } catch {
1747
+ }
1748
+ }
1749
+ }
1750
+ async function createLocalStyle(options) {
1751
+ const id = styleId(options.id);
1752
+ if (options.name !== void 0 && (!options.name.trim() || options.name.length > 200 || /[\x00-\x1f\x7f]/.test(options.name))) {
1753
+ throw new CliError("INVALID_ARGUMENT", "--name must contain 1\u2013200 characters without control characters.", "Use a short, descriptive style name.");
1754
+ }
1755
+ const files = createStyle({ id, name: options.name });
1756
+ const root = await ensureRoot(options.directory, true);
1757
+ await preflightCreate(root, id, files);
1758
+ return withWorkspaceLock(root, async () => {
1759
+ await preflightCreate(root, id, files);
1760
+ const createdFiles = [];
1761
+ const createdDirectories = [];
1762
+ try {
1763
+ for (const [path, content] of Object.entries(files)) {
1764
+ await makeDirectories(root, dirname2(path), createdDirectories);
1765
+ const target = await checkedPath(root, path);
1766
+ const handle = await open4(target, constants4.O_WRONLY | constants4.O_CREAT | constants4.O_EXCL | constants4.O_NOFOLLOW, 420);
1767
+ try {
1768
+ const info = await handle.stat();
1769
+ createdFiles.push({ path, dev: info.dev, ino: info.ino });
1770
+ await handle.writeFile(content, "utf8");
1771
+ } finally {
1772
+ await handle.close();
1773
+ }
1774
+ }
1775
+ } catch (error) {
1776
+ await cleanCreated(root, createdFiles, false);
1777
+ await cleanCreated(root, createdDirectories, true);
1778
+ throw error;
1779
+ }
1780
+ return {
1781
+ root,
1782
+ id,
1783
+ status: "draft",
1784
+ created: Object.keys(files),
1785
+ mediaUploaded: false,
1786
+ 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.`
1787
+ };
1788
+ }, { migrateLegacy: false });
1789
+ }
1790
+ async function readStyle(root, id) {
1791
+ const files = /* @__PURE__ */ Object.create(null);
1792
+ const issues = [];
1793
+ const directories = [];
1794
+ for (const prefix of [".editorbrain", ".editorharness"]) {
1795
+ const path = `${prefix}/styles/${id}`;
1796
+ try {
1797
+ const target = await checkedPath(root, path, false);
1798
+ if (!await exists(target)) continue;
1799
+ if (!(await lstat4(target)).isDirectory()) {
1800
+ issues.push({ path, message: "Style path is not a directory." });
1801
+ continue;
1802
+ }
1803
+ directories.push(path);
1804
+ const entries = await readdir3(target, { withFileTypes: true });
1805
+ for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
1806
+ if (!/\.(json|md)$/.test(entry.name)) continue;
1807
+ const document = `${path}/${entry.name}`;
1808
+ if (!entry.isFile()) {
1809
+ issues.push({ path: document, message: "Style document must be a regular file, not a symlink or directory." });
1810
+ continue;
1811
+ }
1812
+ try {
1813
+ files[document] = await readText(root, document);
1814
+ } catch (error) {
1815
+ issues.push({ path: document, message: `Cannot safely read style document: ${error.message}` });
1816
+ }
1817
+ }
1818
+ } catch (error) {
1819
+ issues.push({ path, message: `Cannot safely read style directory: ${error.message}` });
1820
+ }
1821
+ }
1822
+ 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." });
1823
+ return { files, issues };
1824
+ }
1825
+ async function checkLocalResource(root, path, expected) {
1826
+ safeStyleResourcePath(path);
1827
+ const target = await checkedPath(root, path, false);
1828
+ const handle = await open4(target, constants4.O_RDONLY | constants4.O_NOFOLLOW | constants4.O_NONBLOCK);
1829
+ try {
1830
+ const before = await handle.stat();
1831
+ if (!before.isFile()) throw new Error("Resource is not a regular file.");
1832
+ if (!expected) return {};
1833
+ const hash2 = createHash2("sha256");
1834
+ for await (const chunk of handle.createReadStream({ autoClose: false })) hash2.update(chunk);
1835
+ const after = await handle.stat();
1836
+ const current = await lstat4(await checkedPath(root, path, false));
1837
+ if (before.size !== after.size || before.mtimeMs !== after.mtimeMs || before.ctimeMs !== after.ctimeMs || current.dev !== before.dev || current.ino !== before.ino) {
1838
+ throw new Error("Resource changed while its hash was being checked; check it again.");
1839
+ }
1840
+ return { actualSha256: hash2.digest("hex") };
1841
+ } finally {
1842
+ await handle.close();
1843
+ }
1844
+ }
1845
+ async function checkLocalStyle(options) {
1846
+ const id = styleId(options.id);
1847
+ const root = await ensureRoot(options.directory);
1848
+ const local = await readStyle(root, id);
1849
+ const structural = checkStyle(local.files, id);
1850
+ const localIssues = [...local.issues];
1851
+ const warnings = [...structural.warnings];
1852
+ const resources = [];
1853
+ const resourcePath = Object.keys(local.files).find((path) => path.endsWith("/resources.json"));
1854
+ let document;
1855
+ if (resourcePath) {
1856
+ try {
1857
+ const parsed = JSON.parse(local.files[resourcePath]);
1858
+ if (validateStyleResources(parsed, id).valid) document = parsed;
1859
+ } catch {
1860
+ }
1861
+ }
1862
+ for (const resource of document?.resources ?? []) {
1863
+ const result = { id: resource.id, source: resource.source.type, required: resource.required, status: "unverified" };
1864
+ resources.push(result);
1865
+ if (resource.source.type !== "local") {
1866
+ warnings.push(`Resource ${resource.id}: ${resource.source.type === "asset" ? "remote asset availability and access" : "installed or external requirement"} not verified by this local check.`);
1867
+ continue;
1868
+ }
1869
+ result.path = resource.source.path;
1870
+ result.expectedSha256 = resource.source.sha256;
1871
+ let message;
1872
+ try {
1873
+ const checked = await checkLocalResource(root, resource.source.path, resource.source.sha256);
1874
+ result.actualSha256 = checked.actualSha256;
1875
+ result.status = resource.source.sha256 ? "verified" : "present";
1876
+ if (resource.source.sha256 && checked.actualSha256 !== resource.source.sha256) {
1877
+ result.status = "hash_mismatch";
1878
+ message = "Resource SHA-256 does not match resources.json.";
1879
+ } else if (!resource.source.sha256) warnings.push(`Resource ${resource.id}: local file is present, but its content hash is not pinned.`);
1880
+ } catch (error) {
1881
+ result.status = error.code === "ENOENT" ? "missing" : "invalid";
1882
+ message = result.status === "missing" ? "Local resource is missing." : `Cannot safely verify resource: ${error.message}`;
1883
+ }
1884
+ if (message) {
1885
+ const detail = `Resource ${resource.id}: ${message}`;
1886
+ if (resource.required) localIssues.push({ path: resource.source.path, message: detail });
1887
+ else warnings.push(`Optional ${detail}`);
1888
+ }
1889
+ }
1890
+ const issues = [...structural.issues, ...localIssues];
1891
+ return {
1892
+ root,
1893
+ id,
1894
+ ...structural,
1895
+ valid: issues.length === 0,
1896
+ issues,
1897
+ warnings,
1898
+ localIssues,
1899
+ resources,
1900
+ mediaUploaded: false,
1901
+ quality: "not_assessed"
1902
+ };
1903
+ }
1904
+
1905
+ // packages/cli/src/media-inventory.ts
1906
+ import { createHash as createHash4 } from "node:crypto";
1907
+ import { link, lstat as lstat6, mkdir as mkdir3, mkdtemp, opendir, readFile as readFile2, rm } from "node:fs/promises";
1908
+ import { basename, dirname as dirname4, extname as extname2, join as join2 } from "node:path";
1909
+
1910
+ // packages/cli/src/media-runtime.ts
1911
+ import { spawn as spawn3 } from "node:child_process";
1912
+ import { createHash as createHash3 } from "node:crypto";
1913
+ import { constants as constants5 } from "node:fs";
1914
+ import { lstat as lstat5, open as open5, realpath as realpath2 } from "node:fs/promises";
1915
+ import { dirname as dirname3, extname, parse, resolve as resolve4 } from "node:path";
1916
+ var MediaToolError = class extends CliError {
1917
+ constructor(code, message) {
1918
+ super(code, message, code === "MEDIA_TOOL_NOT_FOUND" ? "Install FFmpeg/FFprobe separately and make them available on PATH; no account or model is needed." : "Review the local input and reported limit. An incomplete check does not validate the media.");
1919
+ this.name = "MediaToolError";
1920
+ }
1921
+ };
1922
+ var SUPPORTED_MEDIA_EXTENSIONS = /* @__PURE__ */ new Set([
1923
+ ".mp4",
1924
+ ".mov",
1925
+ ".m4v",
1926
+ ".mkv",
1927
+ ".webm",
1928
+ ".avi",
1929
+ ".mpg",
1930
+ ".mpeg",
1931
+ ".mts",
1932
+ ".m2ts",
1933
+ ".ts",
1934
+ ".ogv",
1935
+ ".wmv",
1936
+ ".wav",
1937
+ ".mp3",
1938
+ ".m4a",
1939
+ ".aac",
1940
+ ".flac",
1941
+ ".ogg",
1942
+ ".opus",
1943
+ ".aif",
1944
+ ".aiff",
1945
+ ".ac3",
1946
+ ".eac3",
1947
+ ".jpg",
1948
+ ".jpeg",
1949
+ ".png",
1950
+ ".webp",
1951
+ ".bmp",
1952
+ ".tif",
1953
+ ".tiff",
1954
+ ".gif",
1955
+ ".avif",
1956
+ ".heic",
1957
+ ".heif"
1958
+ ]);
1959
+ var PROTOCOLS = "file,pipe";
1960
+ var FORMATS = "mov,matroska,webm,avi,mpeg,mpegts,ogg,wav,flac,mp3,aac,aiff,ac3,eac3,asf,jpeg_pipe,png_pipe,webp_pipe,bmp_pipe,tiff_pipe,gif,heif,avif";
1961
+ var DEFAULT_TIMEOUT = 3e4;
1962
+ var DEFAULT_OUTPUT_LIMIT = 2 * 1024 * 1024;
1963
+ function resolveLocalMediaPath(value) {
1964
+ if (!value || value.includes("\0") || /[\r\n]/.test(value) || /^[a-z][a-z\d+.-]*:/i.test(value) && !(process.platform === "win32" && /^[a-z]:[\\/]/i.test(value)) || value.startsWith("\\\\")) {
1965
+ throw new MediaToolError("MEDIA_LOCAL_PATH_REQUIRED", "Provide a local filesystem path, not a URL, protocol or network share.");
1966
+ }
1967
+ return resolve4(value);
1968
+ }
1969
+ async function assertNoMediaSymlinks(value) {
1970
+ const path = resolveLocalMediaPath(value);
1971
+ let cursor = path;
1972
+ while (cursor !== parse(cursor).root) {
1973
+ const info = await lstat5(cursor);
1974
+ if (info.isSymbolicLink()) {
1975
+ const systemAlias = process.platform === "darwin" && (cursor === "/tmp" || cursor === "/var") && await realpath2(cursor) === `/private${cursor}`;
1976
+ if (!systemAlias) throw new MediaToolError("MEDIA_SYMLINK", "Symlink media paths are not followed. Use the original local path.");
1977
+ }
1978
+ cursor = dirname3(cursor);
1979
+ }
1980
+ return path;
1981
+ }
1982
+ async function assertLocalMediaFile(value) {
1983
+ const path = await assertNoMediaSymlinks(value);
1984
+ const info = await lstat5(path);
1985
+ if (!info.isFile()) throw new MediaToolError("MEDIA_NOT_REGULAR", "The media input must be a regular local file.");
1986
+ if (!SUPPORTED_MEDIA_EXTENSIONS.has(extname(path).toLowerCase())) throw new MediaToolError("MEDIA_FORMAT_UNSUPPORTED", "This file extension is not supported for local media inspection.");
1987
+ return path;
1988
+ }
1989
+ function localInputArgs(path) {
1990
+ return ["-protocol_whitelist", PROTOCOLS, "-format_whitelist", FORMATS, "-i", resolveLocalMediaPath(path)];
1991
+ }
1992
+ async function runMediaTool(tool, args, options = {}) {
1993
+ if (tool !== "ffmpeg" && tool !== "ffprobe") throw new MediaToolError("MEDIA_TOOL_INVALID", "Unsupported media executable.");
1994
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT;
1995
+ const maxOutputBytes = options.maxOutputBytes ?? DEFAULT_OUTPUT_LIMIT;
1996
+ if (!Number.isInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 10 * 6e4 || !Number.isInteger(maxOutputBytes) || maxOutputBytes < 1 || maxOutputBytes > 16 * 1024 * 1024) {
1997
+ throw new MediaToolError("MEDIA_TOOL_LIMITS_INVALID", "Media runtime limits are outside the supported range.");
1998
+ }
1999
+ if (!Array.isArray(args) || args.length > 200 || args.some((arg) => typeof arg !== "string" || arg.length > 8192 || arg.includes("\0"))) {
2000
+ throw new MediaToolError("MEDIA_TOOL_ARGUMENTS_INVALID", "Invalid media tool arguments.");
2001
+ }
2002
+ for (let i = 0; i < args.length; i++) {
2003
+ const arg = args[i];
2004
+ if (/(?:^|[=,;\s])(?:https?|ftp|sftp|tcp|udp|rtsp|rtmp|smb|data|concat|subfile|crypto):/i.test(arg) || arg === "-safe" && args[i + 1] === "0" || arg === "-enable_drefs" && args[i + 1] !== "0") {
2005
+ throw new MediaToolError("MEDIA_REMOTE_INPUT_FORBIDDEN", "Only self-contained local media inputs are allowed.");
2006
+ }
2007
+ if (arg === "-protocol_whitelist" && args[i + 1] !== PROTOCOLS || arg === "-format_whitelist" && args[i + 1] !== FORMATS) {
2008
+ throw new MediaToolError("MEDIA_TOOL_ARGUMENTS_INVALID", "The local media allowlists cannot be widened.");
2009
+ }
2010
+ if (arg === "-i") {
2011
+ await assertLocalMediaFile(args[i + 1] ?? "");
2012
+ if (args[i - 4] !== "-protocol_whitelist" || args[i - 3] !== PROTOCOLS || args[i - 2] !== "-format_whitelist" || args[i - 1] !== FORMATS) {
2013
+ throw new MediaToolError("MEDIA_TOOL_ARGUMENTS_INVALID", "Every media input must use localInputArgs.");
2014
+ }
2015
+ }
2016
+ }
2017
+ return new Promise((resolveResult, reject) => {
2018
+ const stdout = [], stderr = [];
2019
+ let size = 0, done = false;
2020
+ const child = spawn3(tool, ["-hide_banner", ...tool === "ffmpeg" ? ["-nostdin"] : [], ...args], { shell: false, stdio: ["ignore", "pipe", "pipe"], windowsHide: true });
2021
+ const fail = (error) => {
2022
+ if (done) return;
2023
+ done = true;
2024
+ clearTimeout(timer);
2025
+ child.kill("SIGKILL");
2026
+ reject(error);
2027
+ };
2028
+ const timer = setTimeout(() => fail(new MediaToolError("MEDIA_TOOL_TIMEOUT", "The local media check exceeded its time limit. No complete result is available.")), timeoutMs);
2029
+ const collect = (target) => (chunk) => {
2030
+ if (done) return;
2031
+ size += chunk.length;
2032
+ if (size > maxOutputBytes) return fail(new MediaToolError("MEDIA_TOOL_OUTPUT_LIMIT", "The local media check exceeded its output limit. No complete result is available."));
2033
+ target.push(chunk);
2034
+ };
2035
+ child.stdout.on("data", collect(stdout));
2036
+ child.stderr.on("data", collect(stderr));
2037
+ child.on("error", (error) => fail(new MediaToolError(error.code === "ENOENT" ? "MEDIA_TOOL_NOT_FOUND" : "MEDIA_TOOL_FAILED", error.code === "ENOENT" ? `${tool} is not installed or available on PATH. Install it separately to use local media checks.` : "The local media executable could not run.")));
2038
+ child.on("close", (code) => {
2039
+ if (done) return;
2040
+ done = true;
2041
+ clearTimeout(timer);
2042
+ if (code !== 0) return reject(new MediaToolError("MEDIA_TOOL_FAILED", "The local media tool could not inspect or decode this input. No complete result is available."));
2043
+ resolveResult({ stdout: Buffer.concat(stdout).toString("utf8"), stderr: Buffer.concat(stderr).toString("utf8") });
2044
+ });
2045
+ });
2046
+ }
2047
+ var finite2 = (value) => {
2048
+ if (typeof value !== "number" && typeof value !== "string" || value === "") return null;
2049
+ const n = Number(value);
2050
+ return Number.isFinite(n) ? n : null;
2051
+ };
2052
+ var nonnegative2 = (value) => {
2053
+ const n = finite2(value);
2054
+ return n !== null && n >= 0 ? n : null;
2055
+ };
2056
+ var positiveInteger = (value) => {
2057
+ const n = finite2(value);
2058
+ return n !== null && Number.isInteger(n) && n > 0 ? n : void 0;
2059
+ };
2060
+ var rate = (value) => {
2061
+ if (typeof value !== "string" || !/^\d+(?:\/\d+)?$/.test(value)) return null;
2062
+ const [n, d = "1"] = value.split("/");
2063
+ const result = Number(n) / Number(d);
2064
+ return Number.isFinite(result) && result > 0 ? result : null;
2065
+ };
2066
+ var sameFile = (a, b) => a.dev === b.dev && a.ino === b.ino && a.size === b.size && a.mtimeMs === b.mtimeMs && a.ctimeMs === b.ctimeMs;
2067
+ async function probeLocalMedia(value) {
2068
+ const path = await assertLocalMediaFile(value);
2069
+ const handle = await open5(path, constants5.O_RDONLY | (constants5.O_NOFOLLOW ?? 0));
2070
+ try {
2071
+ const before = await handle.stat();
2072
+ if (!before.isFile()) throw new MediaToolError("MEDIA_NOT_REGULAR", "The media input must be a regular local file.");
2073
+ const entries = "format=duration,start_time:stream=index,codec_type,codec_name,width,height,avg_frame_rate,sample_rate,channels,duration:stream_disposition=attached_pic";
2074
+ const { stdout } = await runMediaTool("ffprobe", ["-v", "error", "-show_entries", entries, "-of", "json", ...localInputArgs(path)]);
2075
+ const hash2 = createHash3("sha256");
2076
+ for await (const chunk of handle.createReadStream({ autoClose: false })) hash2.update(chunk);
2077
+ const after = await handle.stat();
2078
+ await assertNoMediaSymlinks(path);
2079
+ const current = await lstat5(path);
2080
+ if (!sameFile(before, after) || !sameFile(after, current)) throw new MediaToolError("MEDIA_FILE_CHANGED", "The media file changed while it was being inspected. Retry with a stable source.");
2081
+ let raw;
2082
+ try {
2083
+ raw = JSON.parse(stdout);
2084
+ } catch {
2085
+ throw new MediaToolError("MEDIA_PROBE_INVALID", "The media probe returned invalid metadata.");
2086
+ }
2087
+ if (!raw || !Array.isArray(raw.streams) || raw.streams.length > 256 || raw.streams.some((stream) => !stream || typeof stream !== "object" || Array.isArray(stream))) throw new MediaToolError("MEDIA_PROBE_INVALID", "The media probe returned invalid or excessive stream metadata.");
2088
+ const streams = raw.streams.map((stream, offset) => {
2089
+ const type = typeof stream.codec_type === "string" && /^(video|audio|subtitle|data|attachment|unknown)$/.test(stream.codec_type) ? stream.codec_type : "unknown";
2090
+ const codec = typeof stream.codec_name === "string" && /^[a-zA-Z0-9_]{1,64}$/.test(stream.codec_name) ? stream.codec_name : null;
2091
+ const index = finite2(stream.index);
2092
+ return {
2093
+ index: index !== null && Number.isInteger(index) && index >= 0 ? index : offset,
2094
+ type,
2095
+ codec,
2096
+ durationSeconds: nonnegative2(stream.duration),
2097
+ ...type === "video" ? { width: positiveInteger(stream.width), height: positiveInteger(stream.height), fps: rate(stream.avg_frame_rate), attachedPicture: stream.disposition?.attached_pic === 1 } : {},
2098
+ ...type === "audio" ? { sampleRate: nonnegative2(stream.sample_rate), channels: positiveInteger(stream.channels) ?? null } : {}
2099
+ };
2100
+ });
2101
+ return { path, sha256: hash2.digest("hex"), bytes: after.size, durationSeconds: nonnegative2(raw.format?.duration), startTimeSeconds: finite2(raw.format?.start_time), streams };
2102
+ } finally {
2103
+ await handle.close();
2104
+ }
2105
+ }
2106
+
2107
+ // packages/cli/src/media-inventory.ts
2108
+ var GENERATED_DIRECTORIES = /* @__PURE__ */ new Set(["node_modules", "dist", "build", "out", "output", "outputs", "renders", "render", "cache", "caches", "proxies", "proxy", "samples", "thumbnails", "coverage", "venv", "__pycache__"]);
2109
+ function mediaFailure(error) {
2110
+ if (error instanceof MediaToolError) return { code: error.code, message: error.message };
2111
+ const code = error?.code;
2112
+ if (code === "ENOENT") return { code: "MEDIA_FILE_MISSING", message: "The local path is missing or changed during inspection." };
2113
+ if (code === "EACCES" || code === "EPERM") return { code: "MEDIA_FILE_UNREADABLE", message: "The local path cannot be read with the current filesystem permissions." };
2114
+ return { code: "MEDIA_INSPECTION_FAILED", message: "Local media inspection failed. No complete result is available for this path." };
2115
+ }
2116
+ async function inventoryMedia(options) {
2117
+ const maxFiles = options.maxFiles ?? 200;
2118
+ if (!Number.isInteger(maxFiles) || maxFiles < 1 || maxFiles > 1e3) throw new MediaToolError("MEDIA_LIMIT_INVALID", "maxFiles must be an integer between 1 and 1000.");
2119
+ const input = resolveLocalMediaPath(options.input);
2120
+ const report = { schemaVersion: 1, kind: "editorbrain.media-inventory", generatedAt: (/* @__PURE__ */ new Date()).toISOString(), assets: [], failures: [], skipped: [], complete: true, scannedEntries: 0, limits: { maxFiles, maxEntries: 1e4, maxDepth: 32 }, contentReviewed: false, mediaUploaded: false };
2121
+ const candidates = [];
2122
+ const skip = (path, reason, incomplete = false) => {
2123
+ report.skipped.push({ path, reason });
2124
+ if (incomplete) report.complete = false;
2125
+ };
2126
+ const visit = async (path, depth, explicit = false) => {
2127
+ if (report.scannedEntries >= report.limits.maxEntries) {
2128
+ skip(path, "entry_limit_reached", true);
2129
+ return;
2130
+ }
2131
+ report.scannedEntries++;
2132
+ try {
2133
+ const stat = await lstat6(path);
2134
+ if (stat.isSymbolicLink()) {
2135
+ if (explicit) throw new MediaToolError("MEDIA_SYMLINK", "Symlink inputs are not followed. Use the original local path.");
2136
+ skip(path, "symlink_not_followed");
2137
+ return;
2138
+ }
2139
+ if (!explicit && basename(path).startsWith(".")) {
2140
+ skip(path, "hidden_entry");
2141
+ return;
2142
+ }
2143
+ await assertNoMediaSymlinks(path);
2144
+ if (stat.isDirectory()) {
2145
+ if (!explicit && GENERATED_DIRECTORIES.has(basename(path).toLowerCase())) {
2146
+ skip(path, "generated_directory");
2147
+ return;
2148
+ }
2149
+ if (depth >= report.limits.maxDepth) {
2150
+ skip(path, "depth_limit_reached", true);
2151
+ return;
2152
+ }
2153
+ const directory = await opendir(path);
2154
+ for await (const entry of directory) {
2155
+ if (report.scannedEntries >= report.limits.maxEntries) {
2156
+ skip(path, "remaining_entries_not_scanned", true);
2157
+ break;
2158
+ }
2159
+ await visit(join2(path, entry.name), depth + 1);
2160
+ }
2161
+ } else if (!stat.isFile()) {
2162
+ if (explicit) throw new MediaToolError("MEDIA_NOT_REGULAR", "The explicit media input must be a regular local file or directory.");
2163
+ skip(path, "not_regular_file");
2164
+ } else if (!SUPPORTED_MEDIA_EXTENSIONS.has(extname2(path).toLowerCase())) {
2165
+ if (explicit) throw new MediaToolError("MEDIA_FORMAT_UNSUPPORTED", "This explicit input is not a supported media format.");
2166
+ skip(path, "unsupported_extension");
2167
+ } else if (candidates.length >= maxFiles) skip(path, "file_limit_reached", true);
2168
+ else candidates.push(path);
2169
+ } catch (error) {
2170
+ report.failures.push({ path, ...mediaFailure(error) });
2171
+ report.complete = false;
2172
+ }
2173
+ };
2174
+ await visit(input, 0, true);
2175
+ for (const path of candidates.sort()) {
2176
+ try {
2177
+ report.assets.push(await probeLocalMedia(path));
2178
+ } catch (error) {
2179
+ report.failures.push({ path, ...mediaFailure(error) });
2180
+ report.complete = false;
2181
+ }
2182
+ }
2183
+ return report;
2184
+ }
2185
+ async function sampleMedia(options) {
2186
+ if (!Array.isArray(options.times) || options.times.length < 1 || options.times.length > 12 || options.times.some((time) => typeof time !== "number" || !Number.isFinite(time) || time < 0 || time > 86400)) {
2187
+ throw new MediaToolError("MEDIA_SAMPLE_TIMES_INVALID", "Provide 1\u201312 sample times in seconds, between 0 and 86400.");
2188
+ }
2189
+ const file = await assertLocalMediaFile(options.file);
2190
+ const source = await probeLocalMedia(file);
2191
+ if (!source.streams.some((stream) => stream.type === "video")) throw new MediaToolError("MEDIA_VIDEO_REQUIRED", "Frame sampling requires a video or image stream.");
2192
+ if (source.durationSeconds !== null && options.times.some((time) => time >= source.durationSeconds)) throw new MediaToolError("MEDIA_SAMPLE_OUT_OF_RANGE", "A requested sample is outside the known media duration.");
2193
+ const outputDirectory = resolveLocalMediaPath(options.outputDirectory);
2194
+ await assertNoMediaSymlinks(dirname4(outputDirectory));
2195
+ try {
2196
+ await mkdir3(outputDirectory);
2197
+ } catch (error) {
2198
+ if (error.code !== "EEXIST") throw error;
2199
+ }
2200
+ await assertNoMediaSymlinks(outputDirectory);
2201
+ if (!(await lstat6(outputDirectory)).isDirectory()) throw new MediaToolError("MEDIA_OUTPUT_DIRECTORY_INVALID", "Frame output must be a local directory.");
2202
+ const temp = await mkdtemp(join2(outputDirectory, ".editorbrain-samples-"));
2203
+ const tempStat = await lstat6(temp);
2204
+ const frames = [];
2205
+ const created = [];
2206
+ try {
2207
+ for (let index = 0; index < options.times.length; index++) {
2208
+ const time = options.times[index];
2209
+ const filename = `${source.sha256.slice(0, 12)}-${String(index + 1).padStart(2, "0")}-${Math.round(time * 1e3)}ms.jpg`;
2210
+ const temporary = join2(temp, filename), output2 = join2(outputDirectory, filename);
2211
+ await runMediaTool("ffmpeg", ["-v", "error", "-n", "-ss", String(time), ...localInputArgs(file), "-map", "0:v:0", "-frames:v", "1", "-an", "-sn", "-dn", "-vf", "scale=960:960:force_original_aspect_ratio=decrease", "-q:v", "3", "-update", "1", temporary], { timeoutMs: 6e4 });
2212
+ const bytes = await readFile2(temporary);
2213
+ if (!bytes.length) throw new MediaToolError("MEDIA_SAMPLE_EMPTY", "The requested frame did not produce a usable image.");
2214
+ try {
2215
+ await link(temporary, output2);
2216
+ } catch (error) {
2217
+ if (error.code === "EEXIST") throw new MediaToolError("MEDIA_OUTPUT_EXISTS", "A sample output already exists. Choose a new output directory; existing files are never overwritten.");
2218
+ throw error;
2219
+ }
2220
+ const outputStat = await lstat6(output2);
2221
+ created.push({ path: output2, ino: outputStat.ino, dev: outputStat.dev, size: outputStat.size, mtimeMs: outputStat.mtimeMs, ctimeMs: outputStat.ctimeMs });
2222
+ frames.push({ path: output2, sha256: createHash4("sha256").update(bytes).digest("hex"), bytes: bytes.length, requestedTimeSeconds: time, actualPresentationTimeSeconds: null });
2223
+ }
2224
+ const after = await probeLocalMedia(file);
2225
+ if (source.sha256 !== after.sha256) throw new MediaToolError("MEDIA_FILE_CHANGED", "The source changed while frames were being sampled. Retry with a stable source.");
2226
+ return { schemaVersion: 1, kind: "editorbrain.media-samples", generatedAt: (/* @__PURE__ */ new Date()).toISOString(), source, frames, metadataOnly: false, contentReviewed: false, mediaUploaded: false };
2227
+ } catch (error) {
2228
+ for (const file2 of created) {
2229
+ try {
2230
+ await assertNoMediaSymlinks(file2.path);
2231
+ const current = await lstat6(file2.path);
2232
+ if (current.ino === file2.ino && current.dev === file2.dev && current.size === file2.size && current.mtimeMs === file2.mtimeMs && current.ctimeMs === file2.ctimeMs) await rm(file2.path, { force: true });
2233
+ } catch {
2234
+ }
2235
+ }
2236
+ throw error;
2237
+ } finally {
2238
+ try {
2239
+ await assertNoMediaSymlinks(temp);
2240
+ const current = await lstat6(temp);
2241
+ if (current.ino === tempStat.ino && current.dev === tempStat.dev) await rm(temp, { recursive: true, force: true });
2242
+ } catch {
2243
+ }
2244
+ }
2245
+ }
2246
+
2247
+ // packages/cli/src/render-check.ts
2248
+ import { resolve as resolve5 } from "node:path";
2249
+ function invalidProfile(message) {
2250
+ throw new CliError("RENDER_PROFILE_INVALID", message, "Use a schemaVersion: 1 render profile with explicit technical requirements.");
2251
+ }
2252
+ function object3(value, path, keys) {
2253
+ if (!value || typeof value !== "object" || Array.isArray(value)) invalidProfile(`${path} must be an object.`);
2254
+ const result = value;
2255
+ for (const key of Object.keys(result)) if (!keys.includes(key)) invalidProfile(`${path} contains unsupported field ${key}.`);
2256
+ return result;
2257
+ }
2258
+ function validateRenderCheckProfile(value) {
2259
+ const root = object3(value, "profile", ["schemaVersion", "expected", "tolerance", "audio", "silence", "black", "peak", "limits"]);
2260
+ if (root.schemaVersion !== 1) invalidProfile("profile.schemaVersion must be 1.");
2261
+ const expected = root.expected === void 0 ? {} : object3(root.expected, "expected", ["width", "height", "fps", "durationSeconds"]);
2262
+ const tolerance = root.tolerance === void 0 ? {} : object3(root.tolerance, "tolerance", ["fps", "durationSeconds"]);
2263
+ const silence = root.silence === void 0 ? {} : object3(root.silence, "silence", ["enabled", "minDurationSeconds", "thresholdDb"]);
2264
+ const black = root.black === void 0 ? {} : object3(root.black, "black", ["enabled", "minDurationSeconds", "pixelThreshold", "pictureBlackRatio"]);
2265
+ const peak = root.peak === void 0 ? {} : object3(root.peak, "peak", ["enabled", "thresholdDbFS"]);
2266
+ const limits = root.limits === void 0 ? {} : object3(root.limits, "limits", ["timeoutMs", "maxOutputBytes"]);
2267
+ const number = (v, name, min, max, fallback, integer = false) => {
2268
+ if (v === void 0 && fallback !== void 0) return fallback;
2269
+ if (typeof v !== "number" || !Number.isFinite(v) || v < min || v > max || integer && !Number.isInteger(v)) invalidProfile(`${name} must be ${integer ? "an integer" : "a finite number"} between ${min} and ${max}.`);
2270
+ return v;
2271
+ };
2272
+ const enabled = (v, name) => {
2273
+ if (v === void 0) return true;
2274
+ if (typeof v !== "boolean") invalidProfile(`${name} must be a boolean.`);
2275
+ return v;
2276
+ };
2277
+ if (root.audio !== void 0 && !["required", "optional", "forbidden"].includes(root.audio)) invalidProfile("audio must be required, optional or forbidden.");
2278
+ const validatedExpected = {};
2279
+ for (const key of ["width", "height"]) if (expected[key] !== void 0) validatedExpected[key] = number(expected[key], `expected.${key}`, 1, 32768, void 0, true);
2280
+ if (expected.fps !== void 0) validatedExpected.fps = number(expected.fps, "expected.fps", 1e-6, 1e3);
2281
+ if (expected.durationSeconds !== void 0) validatedExpected.durationSeconds = number(expected.durationSeconds, "expected.durationSeconds", 1e-6, 864e4);
2282
+ return {
2283
+ schemaVersion: 1,
2284
+ expected: validatedExpected,
2285
+ tolerance: { fps: number(tolerance.fps, "tolerance.fps", 0, 1e3, 0.01), durationSeconds: number(tolerance.durationSeconds, "tolerance.durationSeconds", 0, 86400, 0.1) },
2286
+ audio: root.audio ?? "optional",
2287
+ silence: { enabled: enabled(silence.enabled, "silence.enabled"), minDurationSeconds: number(silence.minDurationSeconds, "silence.minDurationSeconds", 1e-3, 86400, 0.75), thresholdDb: number(silence.thresholdDb, "silence.thresholdDb", -150, 0, -50) },
2288
+ black: { enabled: enabled(black.enabled, "black.enabled"), minDurationSeconds: number(black.minDurationSeconds, "black.minDurationSeconds", 1e-3, 86400, 0.15), pixelThreshold: number(black.pixelThreshold, "black.pixelThreshold", 0, 1, 0.1), pictureBlackRatio: number(black.pictureBlackRatio, "black.pictureBlackRatio", 0, 1, 0.98) },
2289
+ peak: { enabled: enabled(peak.enabled, "peak.enabled"), thresholdDbFS: number(peak.thresholdDbFS, "peak.thresholdDbFS", -150, 0, -0.1) },
2290
+ limits: { timeoutMs: number(limits.timeoutMs, "limits.timeoutMs", 1, 6e5, 6e5, true), maxOutputBytes: number(limits.maxOutputBytes, "limits.maxOutputBytes", 1024, 16777216, 4194304, true) }
2291
+ };
2292
+ }
2293
+ function errorCode(error) {
2294
+ if (error && typeof error === "object" && "code" in error && typeof error.code === "string" && /^[A-Z_0-9]+$/.test(error.code)) return error.code;
2295
+ return "MEDIA_CHECK_FAILED";
2296
+ }
2297
+ function addCheck(report, check) {
2298
+ report.checks.push(check);
2299
+ }
2300
+ function addFinding(report, finding) {
2301
+ report.findingsSummary.total++;
2302
+ report.findingsSummary.byKind[finding.kind]++;
2303
+ if (report.findings.length < 1e3) report.findings.push(finding);
2304
+ else {
2305
+ report.findingsSummary.truncated = true;
2306
+ report.complete = false;
2307
+ }
2308
+ report.findingsSummary.returned = report.findings.length;
2309
+ }
2310
+ function finish(report) {
2311
+ report.technicalStatus = report.checks.some((check) => check.status === "fail") ? "fail" : !report.complete ? "incomplete" : report.checks.some((check) => check.status === "warning") ? "warning" : "pass";
2312
+ return report;
2313
+ }
2314
+ function checkExpected(report, probe, profile) {
2315
+ const expected = profile.expected;
2316
+ if (!Object.keys(expected).length) return addCheck(report, { id: "expected_format", status: "not_applicable", detail: "No target dimensions, frame rate or duration were specified." });
2317
+ const video = probe.streams.find((stream) => stream.type === "video" && !stream.attachedPicture);
2318
+ const mismatches = [];
2319
+ const unknown = [];
2320
+ for (const key of ["width", "height", "fps"]) {
2321
+ const target = expected[key];
2322
+ if (target === void 0) continue;
2323
+ if (!video) {
2324
+ mismatches.push(`Expected ${key}, but the file has no video stream.`);
2325
+ continue;
2326
+ }
2327
+ const actual = video[key];
2328
+ if (actual === void 0 || actual === null || actual <= 0) unknown.push(`Could not measure ${key}.`);
2329
+ else if (Math.abs(actual - target) > (key === "fps" ? profile.tolerance.fps : 0)) mismatches.push(`${key}: measured ${actual}, expected ${target}.`);
2330
+ }
2331
+ if (expected.durationSeconds !== void 0) {
2332
+ if (probe.durationSeconds === null) unknown.push("Could not measure container duration.");
2333
+ else if (Math.abs(probe.durationSeconds - expected.durationSeconds) > profile.tolerance.durationSeconds) mismatches.push(`durationSeconds: measured ${probe.durationSeconds}, expected ${expected.durationSeconds}.`);
2334
+ }
2335
+ if (unknown.length) report.complete = false;
2336
+ addCheck(report, { id: "expected_format", status: mismatches.length ? "fail" : unknown.length ? "not_checked" : "pass", detail: [...mismatches, ...unknown].join(" ") || "Explicit requirements matched; dimensions and reported average frame rate refer to the first video stream excluding attached cover art." });
2337
+ }
2338
+ async function toolVersion(tool) {
2339
+ try {
2340
+ const result = await runMediaTool(tool, ["-version"], { timeoutMs: 1e4, maxOutputBytes: 65536 });
2341
+ return { available: true, version: new RegExp(`^${tool} version ([A-Za-z0-9._+~-]+)`, "m").exec(result.stdout)?.[1] ?? null };
2342
+ } catch {
2343
+ return { available: false, version: null };
2344
+ }
2345
+ }
2346
+ function readDetectorOutput(report, stderr, audioStreams) {
2347
+ const samples = /* @__PURE__ */ new Map();
2348
+ for (const stream of audioStreams) samples.set(stream.index, { streamIndex: stream.index, sampleCount: null, samplePeakDbFS: null, peakState: "unavailable" });
2349
+ const pendingSilence = /* @__PURE__ */ new Map();
2350
+ for (const line of stderr.split("\n")) {
2351
+ const tag = /\[(blackdetect@v|silencedetect@a|astats@a)(\d+)\s+@/.exec(line);
2352
+ if (!tag) continue;
2353
+ const streamIndex = Number(tag[2]);
2354
+ if (tag[1] === "blackdetect@v") {
2355
+ const match = /black_start:([\d.e+-]+)\s+black_end:([\d.e+-]+)\s+black_duration:([\d.e+-]+)/i.exec(line);
2356
+ if (match && match.slice(1).every((value) => Number.isFinite(Number(value)))) addFinding(report, { kind: "black_candidate", streamIndex, startSeconds: Number(match[1]), endSeconds: Number(match[2]), durationSeconds: Number(match[3]), requiresReview: true, message: "A mostly black interval met the configured detector threshold. It may be intentional; review in context." });
2357
+ } else if (tag[1] === "silencedetect@a") {
2358
+ const start = /silence_start:\s*([\d.e+-]+)/i.exec(line);
2359
+ if (start && Number.isFinite(Number(start[1]))) pendingSilence.set(streamIndex, Number(start[1]));
2360
+ const end = /silence_end:\s*([\d.e+-]+)\s*\|\s*silence_duration:\s*([\d.e+-]+)/i.exec(line);
2361
+ if (end && end.slice(1).every((value) => Number.isFinite(Number(value)))) {
2362
+ addFinding(report, { kind: "silence_candidate", streamIndex, startSeconds: pendingSilence.get(streamIndex) ?? Number(end[1]) - Number(end[2]), endSeconds: Number(end[1]), durationSeconds: Number(end[2]), requiresReview: true, message: "A quiet interval met the configured detector threshold. Pauses and silent edits may be intentional." });
2363
+ pendingSilence.delete(streamIndex);
2364
+ }
2365
+ } else {
2366
+ const sample = samples.get(streamIndex);
2367
+ if (!sample) continue;
2368
+ const peak = /Peak level dB:\s*(-inf|[\d.e+-]+)/i.exec(line);
2369
+ if (peak?.[1] === "-inf") sample.peakState = "negative_infinity";
2370
+ else if (peak && Number.isFinite(Number(peak[1]))) {
2371
+ sample.peakState = "measured";
2372
+ sample.samplePeakDbFS = Number(peak[1]);
2373
+ }
2374
+ const count = /Number of samples:\s*([\d.e+-]+)/i.exec(line);
2375
+ if (count && Number.isFinite(Number(count[1]))) sample.sampleCount = Number(count[1]);
2376
+ }
2377
+ }
2378
+ report.measurements.audio = [...samples.values()];
2379
+ if (pendingSilence.size) {
2380
+ report.complete = false;
2381
+ addCheck(report, { id: "silence", status: "not_checked", code: "DETECTOR_OUTPUT_INCOMPLETE", detail: "At least one detected silence interval had no closing timestamp; results are incomplete." });
2382
+ }
2383
+ }
2384
+ async function checkRender(options) {
2385
+ const profile = validateRenderCheckProfile(options.profile ?? { schemaVersion: 1 });
2386
+ const report = {
2387
+ schemaVersion: 1,
2388
+ kind: "editorbrain.render-check",
2389
+ generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
2390
+ mediaUploaded: false,
2391
+ file: { path: resolve5(options.file), sha256: null, bytes: null },
2392
+ effectiveProfile: profile,
2393
+ complete: true,
2394
+ technicalStatus: "incomplete",
2395
+ measurements: { probe: null, decodedThroughSeconds: null, audio: [] },
2396
+ capabilities: { ffprobe: { available: false, version: null }, ffmpeg: { available: false, version: null }, decodeScope: "all_audio_video_streams", timestampBasis: "seconds_from_container_start", peakMethod: "sample_peak_whole_mix_per_audio_stream", truePeak: "not_assessed", loudness: "not_assessed", repeatedFootage: "not_assessed", subtitleStreams: "not_assessed" },
2397
+ checks: [],
2398
+ findings: [],
2399
+ findingsSummary: { total: 0, returned: 0, truncated: false, byKind: { silence_candidate: 0, black_candidate: 0, peak_candidate: 0 } },
2400
+ creativeQuality: { narrative: "not_assessed", readability: "not_assessed", sfxAudibility: "not_assessed", style: "not_assessed" },
2401
+ limitations: [
2402
+ "This report is a technical inspection of the identified file, not approval of its editing or creative quality.",
2403
+ "Default detector thresholds are sensitivities, not requirements for a platform, niche, style, music, pacing or frame rate.",
2404
+ "Silence, black and peak findings require contextual review; no automatic cuts or audio changes are performed.",
2405
+ "Whole-mix sample peaks cannot establish SFX audibility, true-peak clipping, speech clarity or perceived loudness.",
2406
+ "Frame rate is container-reported average; constant frame cadence, HDR, color appearance, rotation and subtitle rendering are not certified.",
2407
+ "Black interval boundaries follow decoded frame timestamps and may differ from presentation duration by a frame.",
2408
+ "Attached cover art is decoded but excluded from video requirements and black-interval detection.",
2409
+ "Reports retain at most 1,000 timestamped findings; exceeding this cap marks the result incomplete and preserves total counts."
2410
+ ]
2411
+ };
2412
+ let probe;
2413
+ try {
2414
+ probe = await probeLocalMedia(options.file);
2415
+ report.file = { path: probe.path, sha256: probe.sha256, bytes: probe.bytes };
2416
+ report.measurements.probe = probe;
2417
+ report.capabilities.ffprobe.available = true;
2418
+ addCheck(report, { id: "probe", status: "pass", detail: "Read stream metadata and SHA-256 from the local file." });
2419
+ } catch (error) {
2420
+ report.complete = false;
2421
+ addCheck(report, { id: "probe", status: "not_checked", code: errorCode(error), detail: "The local file could not be probed. No successful inspection is implied." });
2422
+ for (const id of ["expected_format", "audio_requirement", "decode", "silence", "black", "peak", "file_stability"]) addCheck(report, { id, status: "not_checked", detail: "Requires a successful local media probe." });
2423
+ return finish(report);
2424
+ }
2425
+ const versions = await Promise.all([toolVersion("ffprobe"), toolVersion("ffmpeg")]);
2426
+ report.capabilities.ffprobe = { ...versions[0], available: true };
2427
+ report.capabilities.ffmpeg = versions[1];
2428
+ const video = probe.streams.filter((stream) => stream.type === "video");
2429
+ const motionVideo = video.filter((stream) => !stream.attachedPicture);
2430
+ const audio = probe.streams.filter((stream) => stream.type === "audio");
2431
+ checkExpected(report, probe, profile);
2432
+ const audioMismatch = profile.audio === "required" && !audio.length || profile.audio === "forbidden" && audio.length > 0;
2433
+ addCheck(report, { id: "audio_requirement", status: audioMismatch ? "fail" : profile.audio === "optional" ? "not_applicable" : "pass", detail: `Audio policy: ${profile.audio}; found ${audio.length} audio stream(s). This checks stream presence, not audibility.` });
2434
+ const filters = [];
2435
+ const maps = [];
2436
+ for (const stream of video) {
2437
+ filters.push(`[0:${stream.index}]${profile.black.enabled && !stream.attachedPicture ? `blackdetect@v${stream.index}=d=${profile.black.minDurationSeconds}:pix_th=${profile.black.pixelThreshold}:pic_th=${profile.black.pictureBlackRatio}` : "null"}[v${stream.index}]`);
2438
+ maps.push("-map", `[v${stream.index}]`);
2439
+ }
2440
+ for (const stream of audio) {
2441
+ filters.push(`[0:${stream.index}]${profile.silence.enabled ? `silencedetect@a${stream.index}=n=${profile.silence.thresholdDb}dB:d=${profile.silence.minDurationSeconds},` : ""}astats@a${stream.index}=metadata=0:reset=0:measure_perchannel=none:measure_overall=Peak_level+Number_of_samples[a${stream.index}]`);
2442
+ maps.push("-map", `[a${stream.index}]`);
2443
+ }
2444
+ let decoded = false;
2445
+ if (!filters.length) {
2446
+ addCheck(report, { id: "decode", status: "fail", code: "NO_PLAYABLE_STREAMS", detail: "No audio or video streams are available for inspection." });
2447
+ } else {
2448
+ try {
2449
+ const result = await runMediaTool("ffmpeg", ["-hide_banner", "-nostdin", "-nostats", "-loglevel", "info", "-xerror", "-copyts", "-start_at_zero", ...localInputArgs(probe.path), "-filter_complex", filters.join(";"), ...maps, "-progress", "pipe:1", "-stats_period", "60", "-f", "null", "-"], profile.limits);
2450
+ report.capabilities.ffmpeg.available = true;
2451
+ readDetectorOutput(report, result.stderr, audio);
2452
+ const progress = [...result.stdout.matchAll(/^out_time_us=(-?\d+)$/gm)].at(-1);
2453
+ report.measurements.decodedThroughSeconds = progress ? Number(progress[1]) / 1e6 : null;
2454
+ const frames = [...result.stdout.matchAll(/^frame=(\d+)$/gm)].at(-1);
2455
+ const empty = video.length > 0 && !!frames && Number(frames[1]) <= 0 || audio.length > 0 && report.measurements.audio.some((stream) => stream.sampleCount === 0);
2456
+ const missingEvidence = !/^progress=end$/m.test(result.stdout) || video.length > 0 && !frames || audio.length > 0 && report.measurements.audio.some((stream) => stream.sampleCount === null);
2457
+ if (empty) addCheck(report, { id: "decode", status: "fail", code: "EMPTY_DECODE", detail: "An expected stream produced no decoded frames or samples." });
2458
+ else if (missingEvidence) {
2459
+ report.complete = false;
2460
+ addCheck(report, { id: "decode", status: "not_checked", code: "DECODE_EVIDENCE_MISSING", detail: "The tool exited without complete decode evidence. Empty or incomplete logs are not a pass." });
2461
+ } else {
2462
+ decoded = true;
2463
+ addCheck(report, { id: "decode", status: "pass", detail: "FFmpeg decoded the complete file to end-of-input with explicit audio/video maps and no reported fatal decoding error." });
2464
+ }
2465
+ } catch (error) {
2466
+ const code = errorCode(error);
2467
+ const failed = code === "MEDIA_TOOL_FAILED";
2468
+ report.complete = false;
2469
+ addCheck(report, { id: "decode", status: failed ? "fail" : "not_checked", code, detail: failed ? "FFmpeg did not finish successfully. Inspect the file or local tool compatibility; this does not establish creative quality." : "Full-file decoding did not complete within the available tools or limits; detector results are incomplete." });
2470
+ }
2471
+ }
2472
+ for (const id of ["silence", "black", "peak"]) {
2473
+ if (report.checks.some((check) => check.id === id)) continue;
2474
+ const applicable = id === "black" ? motionVideo.length > 0 : audio.length > 0;
2475
+ if (!applicable) {
2476
+ addCheck(report, { id, status: "not_applicable", detail: `No ${id === "black" ? "video streams excluding attached cover art" : "audio streams"}.` });
2477
+ continue;
2478
+ }
2479
+ if (!profile[id].enabled) {
2480
+ addCheck(report, { id, status: "not_checked", detail: "Explicitly disabled in the effective profile." });
2481
+ continue;
2482
+ }
2483
+ if (!decoded) {
2484
+ addCheck(report, { id, status: "not_checked", detail: "Requires successful full-file decoding." });
2485
+ continue;
2486
+ }
2487
+ if (id === "peak") {
2488
+ if (report.measurements.audio.some((stream) => stream.peakState === "unavailable")) {
2489
+ report.complete = false;
2490
+ addCheck(report, { id, status: "not_checked", code: "DETECTOR_OUTPUT_INCOMPLETE", detail: "A whole-mix sample peak could not be read for every audio stream." });
2491
+ continue;
2492
+ }
2493
+ for (const stream of report.measurements.audio) if (stream.samplePeakDbFS !== null && stream.samplePeakDbFS >= profile.peak.thresholdDbFS) addFinding(report, { kind: "peak_candidate", streamIndex: stream.streamIndex, measuredDbFS: stream.samplePeakDbFS, requiresReview: true, message: "Whole-mix sample peak reached the configured threshold. Check possible clipping or limiting; peak level alone is not proof of distortion." });
2494
+ }
2495
+ const count = report.findingsSummary.byKind[`${id}_candidate`];
2496
+ addCheck(report, { id, status: count ? "warning" : "pass", detail: count ? `${count} candidate(s) require contextual review; they are not automatic editing errors.` : "No candidates met the effective detector threshold during full-file decoding." });
2497
+ }
2498
+ try {
2499
+ const after = await probeLocalMedia(probe.path);
2500
+ const same = after.sha256 === probe.sha256 && after.bytes === probe.bytes;
2501
+ if (!same) report.complete = false;
2502
+ addCheck(report, { id: "file_stability", status: same ? "pass" : "fail", ...same ? {} : { code: "MEDIA_CHANGED_DURING_CHECK" }, detail: same ? "SHA-256 and byte length matched before and after inspection." : "The file changed during inspection; findings must not be treated as a verified report for the current file." });
2503
+ } catch (error) {
2504
+ report.complete = false;
2505
+ addCheck(report, { id: "file_stability", status: "not_checked", code: errorCode(error), detail: "The input fingerprint could not be verified after inspection." });
2506
+ }
2507
+ return finish(report);
2508
+ }
2509
+
2510
+ // packages/cli/src/production-output.ts
2511
+ import { constants as constants6 } from "node:fs";
2512
+ import { mkdir as mkdir4, open as open6 } from "node:fs/promises";
2513
+ import { dirname as dirname5 } from "node:path";
2514
+ async function productionOutputPath(root, relativePath) {
2515
+ if (!relativePath.startsWith(".editorbrain-local/") || relativePath.split(/[\\/]/).some((p) => !p || p === ".." || p === ".") || /[\\\x00-\x1f\x7f]/.test(relativePath)) {
2516
+ throw new CliError("LOCAL_OUTPUT_REQUIRED", "Production output must be inside .editorbrain-local/.", "Use --out .editorbrain-local/<project>/inventory.json, or omit --out for JSON stdout. These reports are not synced.");
2517
+ }
2518
+ return checkedPath(root, relativePath, false);
2519
+ }
2520
+ async function prepareSampleDirectory(root, path) {
2521
+ const target = await productionOutputPath(root, path);
2522
+ await mkdir4(dirname5(target), { recursive: true });
2523
+ await productionOutputPath(root, path);
2524
+ return target;
2525
+ }
2526
+ async function readProductionJson(root, path) {
2527
+ const target = await checkedPath(root, path, false);
2528
+ const handle = await open6(target, constants6.O_RDONLY | constants6.O_NOFOLLOW | constants6.O_NONBLOCK);
2529
+ try {
2530
+ const stat = await handle.stat();
2531
+ if (!stat.isFile() || stat.size > 16 * 1024 * 1024) throw new CliError("INVALID_REPORT", "Expected a regular JSON file of at most 16 MiB.", "Select a local inventory, plan or check profile.");
2532
+ return JSON.parse(await handle.readFile("utf8"));
2533
+ } finally {
2534
+ await handle.close();
2535
+ }
2536
+ }
2537
+ async function saveProductionReport(root, path, report) {
2538
+ const target = await productionOutputPath(root, path);
2539
+ if (!path.endsWith(".json")) throw new CliError("INVALID_ARGUMENT", "Report output needs a .json extension.", "Use a new path under .editorbrain-local/.");
2540
+ await atomicWrite(root, path, stableJson(report), { document: false, overwrite: false, mode: 384 });
2541
+ return target;
2542
+ }
2543
+ function summarizeProductionReport(value) {
2544
+ const r = value;
2545
+ const base = { schemaVersion: r.schemaVersion, kind: r.kind, summary: true, mediaUploaded: false, quality: "not_assessed" };
2546
+ if (r.kind === "editorbrain.media-inventory") return {
2547
+ ...base,
2548
+ complete: r.complete,
2549
+ contentReviewed: false,
2550
+ counts: { assets: r.assets.length, failures: r.failures.length, skipped: r.skipped.length },
2551
+ assets: r.assets.slice(0, 8),
2552
+ failures: r.failures.slice(0, 5),
2553
+ omitted: { assets: Math.max(0, r.assets.length - 8), failures: Math.max(0, r.failures.length - 5), skipped: r.skipped.length },
2554
+ next: "Inspect selected source content. Use media sample for specific frames. Read the saved report or rerun with --details for omitted metadata; no content was reviewed automatically."
2555
+ };
2556
+ if (r.kind === "editorbrain.media-samples") return { ...base, sourceSha256: r.source.sha256, frames: r.frames, contentReviewed: false, next: "View these frames to inspect composition. They do not prove motion or sound." };
2557
+ if (r.kind === "editorbrain.production-plan-check") return {
2558
+ ...base,
2559
+ valid: r.valid,
2560
+ inventoryComplete: r.inventoryComplete,
2561
+ observations: r.observations,
2562
+ sourceFilesReverified: false,
2563
+ counts: { sources: r.coverage.length, findings: r.findings.length, repeatedPairs: r.repeatedPairs ?? 0 },
2564
+ findings: [...r.findings].sort((a, b) => ({ error: 0, warning: 1, info: 2 })[a.severity] - { error: 0, warning: 1, info: 2 }[b.severity]).slice(0, 8),
2565
+ omittedFindings: Math.max(0, r.findings.length - 8),
2566
+ next: "Review source reuse and inspection gaps in the saved report. Observations are agent-declared, not independently certified."
2567
+ };
2568
+ if (r.kind === "editorbrain.render-check") return {
2569
+ ...base,
2570
+ complete: r.complete,
2571
+ technicalStatus: r.technicalStatus,
2572
+ file: r.file,
2573
+ checks: r.checks,
2574
+ findings: r.findings.slice(0, 8),
2575
+ findingsSummary: r.findingsSummary,
2576
+ omittedFindings: Math.max(0, r.findings.length - 8),
2577
+ next: "Reconcile technical candidates with the brief; inspect the actual video/audio. Technical checks do not assess narrative, aesthetics or SFX audibility."
2578
+ };
2579
+ throw new CliError("INVALID_REPORT", "Unknown production report kind.", "Use media inventory/sample or production check/plan-check.");
2580
+ }
2581
+
1377
2582
  // packages/cli/src/index.ts
1378
2583
  var HELP = `EditorBrain \u2014 portable editorial context for AI video agents
1379
2584
 
@@ -1414,6 +2619,12 @@ editorbrain library media <video-id> --sha256 media-hash --out file
1414
2619
  editorbrain narrative search [query] [--kind hook|cta|...] [--channel visual|spoken|...] [--limit 10]
1415
2620
  editorbrain narrative get <video-id> [--sha256 document-hash] [--event-offset 0] [--relation-offset 0] [--limit 50]
1416
2621
  editorbrain styles list
2622
+ editorbrain styles create <style-id> [--name name] [--dir folder]
2623
+ editorbrain styles check <style-id> [--dir folder]
2624
+ editorbrain media inventory --input file-or-folder [--max-files 200] [--dir folder] [--out .editorbrain-local/report.json] [--details]
2625
+ editorbrain media sample --file source --at 0,5,12 --out .editorbrain-local/frames [--dir folder] [--details]
2626
+ editorbrain production check --file render [--profile local-profile.json] [--dir folder] [--out .editorbrain-local/report.json] [--details]
2627
+ editorbrain production plan-check --inventory local-inventory.json --plan local-plan.json [--dir folder] [--out .editorbrain-local/report.json] [--details]
1417
2628
  editorbrain styles recommend <project-id> [--assets tag,tag] [--capabilities tag,tag] [--style pack-id] [--duration seconds]
1418
2629
  editorbrain direction draft <project-id> [--assets tag,tag] [--capabilities tag,tag] [--style pack-id] [--duration seconds] [--out new-folder]
1419
2630
  editorbrain assets list [--limit 200] [--cursor opaque-cursor]
@@ -1428,14 +2639,17 @@ Secrets go to a user config with mode 0600, never the portable project.
1428
2639
  Resolve writes the source-hash lock. Export produces a handoff, not a rendered video.
1429
2640
  Push/pull preserve conflicting changes; media uploads use the separate assets command.
1430
2641
  Skill install works offline, preserves existing instructions, and defaults to both agents.
2642
+ Styles create/check work locally without login or media upload; checks do not certify render quality.
2643
+ Media/production commands run locally with ffprobe/ffmpeg, without automatic installation or media upload.
2644
+ Production output is compact by default; --details expands it. --out saves full private reports without overwriting.
1431
2645
  Keys create stores the returned secret in the private user config; stdout has no key.
1432
2646
  Output is JSON except help; login progress/code goes to stderr. --url overrides the configured endpoint.
1433
2647
  `;
1434
2648
  function parseArgs(argv) {
1435
2649
  const positional = [];
1436
2650
  const flags = {};
1437
- const booleans = /* @__PURE__ */ new Set(["help", "version", "offline", "dry-run", "key-stdin", "invite-stdin", "no-browser", "skip-mcp", "all", "share-authorized"]);
1438
- 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]);
2651
+ const booleans = /* @__PURE__ */ new Set(["help", "version", "offline", "dry-run", "key-stdin", "invite-stdin", "no-browser", "skip-mcp", "all", "share-authorized", "details"]);
2652
+ const known = /* @__PURE__ */ new Set(["input", "max-files", "at", "profile", "inventory", "plan", "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]);
1439
2653
  for (let i = 0; i < argv.length; i++) {
1440
2654
  const arg = argv[i];
1441
2655
  if (!arg.startsWith("--")) {
@@ -1461,16 +2675,16 @@ function output(value) {
1461
2675
  process.stdout.write(stableJson(value));
1462
2676
  }
1463
2677
  async function configRoot(create = false) {
1464
- let path = process.env.EDITORBRAIN_CONFIG_DIR ?? process.env.EDITORHARNESS_CONFIG_DIR ?? join2(homedir(), ".config", "editorbrain");
2678
+ let path = process.env.EDITORBRAIN_CONFIG_DIR ?? process.env.EDITORHARNESS_CONFIG_DIR ?? join3(homedir(), ".config", "editorbrain");
1465
2679
  if (!create && !process.env.EDITORBRAIN_CONFIG_DIR && !process.env.EDITORHARNESS_CONFIG_DIR) {
1466
2680
  try {
1467
- await lstat4(path);
2681
+ await lstat7(path);
1468
2682
  } catch (error) {
1469
2683
  if (error.code !== "ENOENT") throw error;
1470
- path = join2(homedir(), ".config", "editorharness");
2684
+ path = join3(homedir(), ".config", "editorharness");
1471
2685
  }
1472
2686
  }
1473
- if (create) await mkdir2(path, { recursive: true, mode: 448 });
2687
+ if (create) await mkdir5(path, { recursive: true, mode: 448 });
1474
2688
  return ensureRoot(path);
1475
2689
  }
1476
2690
  async function loadConfig() {
@@ -1487,7 +2701,7 @@ async function loadConfig() {
1487
2701
  async function saveConfig(config2) {
1488
2702
  const root = await configRoot(true);
1489
2703
  await atomicWrite(root, "config.json", stableJson(config2), { document: false, mode: 384 });
1490
- await chmod(join2(root, "config.json"), 384);
2704
+ await chmod(join3(root, "config.json"), 384);
1491
2705
  }
1492
2706
  async function config(args, requireKey = true) {
1493
2707
  const stored = await loadConfig();
@@ -1515,7 +2729,7 @@ async function init(args) {
1515
2729
  for (const path of Object.keys(files)) {
1516
2730
  const target = await checkedPath(root, path);
1517
2731
  try {
1518
- await lstat4(target);
2732
+ await lstat7(target);
1519
2733
  throw new Error(`Refusing to replace existing file: ${path}`);
1520
2734
  } catch (error) {
1521
2735
  if (error.code !== "ENOENT") throw error;
@@ -1527,13 +2741,13 @@ async function init(args) {
1527
2741
  for (const path of directories) {
1528
2742
  const target = await checkedPath(root, path, false);
1529
2743
  try {
1530
- if (!(await lstat4(target)).isDirectory()) throw new Error(`Not a directory: ${path}`);
2744
+ if (!(await lstat7(target)).isDirectory()) throw new Error(`Not a directory: ${path}`);
1531
2745
  } catch (error) {
1532
2746
  if (error.code !== "ENOENT") throw error;
1533
2747
  }
1534
2748
  }
1535
2749
  for (const [path, content] of Object.entries(files)) await atomicWrite(root, path, content, { overwrite: false });
1536
- for (const path of directories) await mkdir2(await checkedPath(root, path, false), { recursive: true });
2750
+ for (const path of directories) await mkdir5(await checkedPath(root, path, false), { recursive: true });
1537
2751
  const guidance = await installGuidance();
1538
2752
  const entryPoints = guidance.readInstructions.filter((item) => item.entryPointStatus === "created").map((item) => item.entryPoint);
1539
2753
  output({ root, created: Object.keys(files), entryPoints, guidance, next: `Edit brand tokens and brief, then run editorbrain resolve ${project}` });
@@ -1593,6 +2807,49 @@ async function main() {
1593
2807
  process.stdout.write(HELP);
1594
2808
  return;
1595
2809
  }
2810
+ if (args.flags.details && !["media", "production"].includes(command)) throw new CliError("INVALID_ARGUMENT", "--details is supported only by media and production.", "Read editorbrain help.");
2811
+ if (command === "media" || command === "production") {
2812
+ const required = (name) => {
2813
+ const value = str(args, name);
2814
+ if (!value) throw new CliError("INVALID_ARGUMENT", `--${name} is required.`, "Read editorbrain help.");
2815
+ return value;
2816
+ };
2817
+ if (argument) throw new CliError("INVALID_ARGUMENT", "Media/production inputs use named flags.", "Read editorbrain help.");
2818
+ const allowed = /* @__PURE__ */ new Set(["dir", "out", "details", ...command === "media" && subcommand === "inventory" ? ["input", "max-files"] : command === "media" && subcommand === "sample" ? ["file", "at"] : command === "production" && subcommand === "check" ? ["file", "profile"] : command === "production" && subcommand === "plan-check" ? ["inventory", "plan"] : []]);
2819
+ for (const flag of Object.keys(args.flags)) if (!allowed.has(flag)) throw new CliError("INVALID_ARGUMENT", `--${flag} is not supported for this local command.`, "Read editorbrain help.");
2820
+ const root2 = await ensureRoot(str(args, "dir") ?? process.cwd());
2821
+ const out = str(args, "out");
2822
+ if (out) await productionOutputPath(root2, out);
2823
+ const run2 = async () => {
2824
+ let report;
2825
+ if (command === "media" && subcommand === "inventory") {
2826
+ const limit = str(args, "max-files");
2827
+ report = await inventoryMedia({ input: resolve6(root2, required("input")), ...limit ? { maxFiles: Number(limit) } : {} });
2828
+ if (!report.complete) process.exitCode = 2;
2829
+ } else if (command === "media" && subcommand === "sample") {
2830
+ const times = required("at").split(",");
2831
+ if (times.some((t) => !t.trim() || !/^(?:\d+(?:\.\d*)?|\.\d+)$/.test(t.trim()))) throw new CliError("INVALID_ARGUMENT", "--at requires comma-separated nonnegative seconds.", "For example: --at 0,5.5,12");
2832
+ const directory = await prepareSampleDirectory(root2, required("out"));
2833
+ report = await sampleMedia({ file: resolve6(root2, required("file")), times: times.map(Number), outputDirectory: directory });
2834
+ } else if (command === "production" && subcommand === "check") {
2835
+ const profilePath = str(args, "profile");
2836
+ const profile = profilePath ? validateRenderCheckProfile(await readProductionJson(root2, profilePath)) : void 0;
2837
+ report = await checkRender({ file: resolve6(root2, required("file")), profile });
2838
+ if (report.technicalStatus === "fail") process.exitCode = 1;
2839
+ else if (!report.complete) process.exitCode = 2;
2840
+ } else if (command === "production" && subcommand === "plan-check") {
2841
+ const [inventory, plan] = await Promise.all([readProductionJson(root2, required("inventory")), readProductionJson(root2, required("plan"))]);
2842
+ report = checkProductionPlan(inventory, plan);
2843
+ if (!report.valid) process.exitCode = 1;
2844
+ else if (!report.inventoryComplete) process.exitCode = 2;
2845
+ } else throw new CliError("INVALID_ARGUMENT", "Unknown media/production command.", "Use media inventory/sample or production check/plan-check.");
2846
+ const reportPath = out && subcommand !== "sample" ? await saveProductionReport(root2, out, report) : void 0;
2847
+ output({ ...args.flags.details ? report : summarizeProductionReport(report), ...reportPath ? { reportPath } : {} });
2848
+ };
2849
+ if (out) await withWorkspaceLock(root2, run2, { migrateLegacy: false });
2850
+ else await run2();
2851
+ return;
2852
+ }
1596
2853
  if (command === "doctor") {
1597
2854
  let selected, configError;
1598
2855
  if (!args.flags.offline) {
@@ -1639,6 +2896,20 @@ async function main() {
1639
2896
  output({ endpoint: client.url, ...await client.me() });
1640
2897
  return;
1641
2898
  }
2899
+ if (command === "styles" && (subcommand === "create" || subcommand === "check")) {
2900
+ const allowed = new Set(subcommand === "create" ? ["dir", "name"] : ["dir"]);
2901
+ if (args.positional.length !== 3 || Object.keys(args.flags).some((flag) => !allowed.has(flag))) {
2902
+ 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.");
2903
+ }
2904
+ const options = { directory: str(args, "dir") ?? process.cwd(), id: argument };
2905
+ if (subcommand === "create") output(await createLocalStyle({ ...options, name: str(args, "name") }));
2906
+ else {
2907
+ const result = await checkLocalStyle(options);
2908
+ output(result);
2909
+ if (!result.valid) process.exitCode = 1;
2910
+ }
2911
+ return;
2912
+ }
1642
2913
  if (command === "styles" || command === "direction") {
1643
2914
  const client = new Client(await config(args));
1644
2915
  if (command === "styles" && subcommand === "list") {
@@ -1661,10 +2932,10 @@ async function main() {
1661
2932
  const destination = str(args, "out");
1662
2933
  if (destination) {
1663
2934
  if (command !== "direction" || !result.files) throw new Error("--out is supported only for direction draft.");
1664
- const outRoot = await ensureRoot(resolve4(str(args, "dir") ?? process.cwd(), destination), true);
2935
+ const outRoot = await ensureRoot(resolve6(str(args, "dir") ?? process.cwd(), destination), true);
1665
2936
  for (const path of Object.keys(result.files)) {
1666
2937
  try {
1667
- await lstat4(await checkedPath(outRoot, path, false));
2938
+ await lstat7(await checkedPath(outRoot, path, false));
1668
2939
  throw new Error(`Refusing to replace existing proposal: ${path}`);
1669
2940
  } catch (error) {
1670
2941
  if (error.code !== "ENOENT") throw error;
@@ -1690,7 +2961,7 @@ async function main() {
1690
2961
  if (!/^[a-zA-Z0-9_-]{1,100}$/.test(result.id) || typeof result.apiKey !== "string") throw new Error("Invalid key response");
1691
2962
  const file = `keys/${result.id}.json`;
1692
2963
  await atomicWrite(root2, file, stableJson({ url: client.url, apiKey: result.apiKey, id: result.id, label, role }), { document: false, mode: 384, overwrite: false });
1693
- output({ id: result.id, label, role, credentialFile: join2(root2, file) });
2964
+ output({ id: result.id, label, role, credentialFile: join3(root2, file) });
1694
2965
  return;
1695
2966
  }
1696
2967
  if (subcommand === "revoke" && argument) {
@@ -1777,8 +3048,8 @@ async function main() {
1777
3048
  return;
1778
3049
  }
1779
3050
  if (subcommand === "upload" && argument) {
1780
- const target = resolve4(str(args, "dir") ?? process.cwd(), argument);
1781
- const handle = await open4(target, constants4.O_RDONLY | constants4.O_NOFOLLOW);
3051
+ const target = resolve6(str(args, "dir") ?? process.cwd(), argument);
3052
+ const handle = await open7(target, constants7.O_RDONLY | constants7.O_NOFOLLOW);
1782
3053
  let bytes;
1783
3054
  try {
1784
3055
  const info = await handle.stat();
@@ -1788,24 +3059,24 @@ async function main() {
1788
3059
  await handle.close();
1789
3060
  }
1790
3061
  const types = { ".mp4": "video/mp4", ".mov": "video/quicktime", ".webm": "video/webm", ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".webp": "image/webp", ".mp3": "audio/mpeg", ".wav": "audio/wav", ".m4a": "audio/mp4", ".ogg": "audio/ogg" };
1791
- const mimeType = str(args, "mime") ?? types[extname(target).toLowerCase()];
3062
+ const mimeType = str(args, "mime") ?? types[extname3(target).toLowerCase()];
1792
3063
  if (!mimeType) throw new Error("Unsupported extension; specify --mime for a supported media format");
1793
- const hash = await sha256(bytes);
1794
- const result = await client.media(`/v1/assets/${hash}?name=${encodeURIComponent(basename(target))}`, { content: bytes, mimeType });
3064
+ const hash2 = await sha256(bytes);
3065
+ const result = await client.media(`/v1/assets/${hash2}?name=${encodeURIComponent(basename2(target))}`, { content: bytes, mimeType });
1795
3066
  const body = await result.json();
1796
- if (body.sha256 !== hash) throw new Error("Upload response hash mismatch");
3067
+ if (body.sha256 !== hash2) throw new Error("Upload response hash mismatch");
1797
3068
  output(body);
1798
3069
  return;
1799
3070
  }
1800
3071
  if (subcommand === "download" && argument && str(args, "out")) {
1801
3072
  if (!/^[a-f0-9]{64}$/.test(argument)) throw new Error("Asset ID must be its SHA-256");
1802
- const destination = resolve4(str(args, "dir") ?? process.cwd(), str(args, "out"));
1803
- const root2 = await ensureRoot(dirname2(destination), true);
1804
- await checkedPath(root2, basename(destination), false);
3073
+ const destination = resolve6(str(args, "dir") ?? process.cwd(), str(args, "out"));
3074
+ const root2 = await ensureRoot(dirname6(destination), true);
3075
+ await checkedPath(root2, basename2(destination), false);
1805
3076
  const response = await client.media(`/v1/assets/${argument}`);
1806
3077
  const bytes = new Uint8Array(await response.arrayBuffer());
1807
3078
  if (await sha256(bytes) !== argument) throw new Error("Downloaded asset failed SHA-256 verification");
1808
- await atomicWrite(root2, basename(destination), bytes, { document: false, overwrite: false });
3079
+ await atomicWrite(root2, basename2(destination), bytes, { document: false, overwrite: false });
1809
3080
  output({ path: destination, sha256: argument, bytes: bytes.length });
1810
3081
  return;
1811
3082
  }
@@ -1835,7 +3106,7 @@ async function main() {
1835
3106
  if (command === "search" && subcommand === "brief") {
1836
3107
  const path = str(args, "file");
1837
3108
  if (!path) throw new Error("Provide --file with the editorial brief JSON after inspecting the actual material.");
1838
- const handle = await open4(resolve4(path), constants4.O_RDONLY | constants4.O_NOFOLLOW);
3109
+ const handle = await open7(resolve6(path), constants7.O_RDONLY | constants7.O_NOFOLLOW);
1839
3110
  let brief;
1840
3111
  try {
1841
3112
  const stat = await handle.stat();
@@ -1857,7 +3128,7 @@ async function main() {
1857
3128
  const scriptPath = str(args, "script"), videoPath = str(args, "video");
1858
3129
  let script, assetId = str(args, "asset");
1859
3130
  const readInput = async (path, max) => {
1860
- const handle = await open4(resolve4(path), constants4.O_RDONLY | constants4.O_NOFOLLOW);
3131
+ const handle = await open7(resolve6(path), constants7.O_RDONLY | constants7.O_NOFOLLOW);
1861
3132
  try {
1862
3133
  const stat = await handle.stat();
1863
3134
  if (!stat.isFile() || stat.size < 1 || stat.size > max) throw new Error(`Input must be a regular file of at most ${max} bytes.`);
@@ -1877,10 +3148,10 @@ async function main() {
1877
3148
  if (assetId && !/^[a-f0-9]{64}$/.test(assetId)) throw new Error("--asset requires an owned production asset SHA-256.");
1878
3149
  if (videoPath) {
1879
3150
  const bytes = await readInput(videoPath, 12 * 1024 * 1024);
1880
- const mimeType = { ".mp4": "video/mp4", ".mov": "video/quicktime", ".webm": "video/webm" }[extname(videoPath).toLowerCase()];
3151
+ const mimeType = { ".mp4": "video/mp4", ".mov": "video/quicktime", ".webm": "video/webm" }[extname3(videoPath).toLowerCase()];
1881
3152
  if (!mimeType) throw new Error("Use MP4, MOV or WebM.");
1882
3153
  assetId = await sha256(bytes);
1883
- const uploaded = await client.media(`/v1/assets/${assetId}?name=${encodeURIComponent(basename(videoPath))}`, { content: bytes, mimeType });
3154
+ const uploaded = await client.media(`/v1/assets/${assetId}?name=${encodeURIComponent(basename2(videoPath))}`, { content: bytes, mimeType });
1884
3155
  if ((await uploaded.json()).sha256 !== assetId) throw new Error("Upload hash mismatch.");
1885
3156
  }
1886
3157
  if (!script && !assetId && !str(args, "project")) throw new Error("Provide --script, --video, --asset or --project with a saved script.");
@@ -1895,20 +3166,20 @@ async function main() {
1895
3166
  }
1896
3167
  if (subcommand === "get" && argument) {
1897
3168
  const document = str(args, "document");
1898
- const hash = str(args, "sha256");
1899
- if (hash && !document) throw new Error("--sha256 requires --document for library get.");
1900
- const result = await client.request(`/v1/library/${encodeURIComponent(argument)}${document ? `/documents?${new URLSearchParams({ path: document, ...hash ? { sha256: hash } : {} })}` : ""}`);
1901
- if (document && (typeof result.content !== "string" || await sha256(result.content) !== result.sha256 || hash && result.sha256 !== hash)) throw new CliError("HASH_MISMATCH", "Reference document verification failed.", "Do not use this evidence; retry the pinned version.");
3169
+ const hash2 = str(args, "sha256");
3170
+ if (hash2 && !document) throw new Error("--sha256 requires --document for library get.");
3171
+ const result = await client.request(`/v1/library/${encodeURIComponent(argument)}${document ? `/documents?${new URLSearchParams({ path: document, ...hash2 ? { sha256: hash2 } : {} })}` : ""}`);
3172
+ if (document && (typeof result.content !== "string" || await sha256(result.content) !== result.sha256 || hash2 && result.sha256 !== hash2)) throw new CliError("HASH_MISMATCH", "Reference document verification failed.", "Do not use this evidence; retry the pinned version.");
1902
3173
  output(result);
1903
3174
  return;
1904
3175
  }
1905
3176
  if (subcommand === "media" && argument && str(args, "out")) {
1906
- const hash = str(args, "sha256");
1907
- if (!hash || !/^[a-f0-9]{64}$/.test(hash)) throw new Error("--sha256 must be a media SHA-256 from library get.");
1908
- const destination = resolve4(str(args, "dir") ?? process.cwd(), str(args, "out"));
1909
- const root2 = await ensureRoot(dirname2(destination), true);
1910
- await checkedPath(root2, basename(destination), false);
1911
- const response = await client.media(`/v1/library/${encodeURIComponent(argument)}/media/${hash}`);
3177
+ const hash2 = str(args, "sha256");
3178
+ if (!hash2 || !/^[a-f0-9]{64}$/.test(hash2)) throw new Error("--sha256 must be a media SHA-256 from library get.");
3179
+ const destination = resolve6(str(args, "dir") ?? process.cwd(), str(args, "out"));
3180
+ const root2 = await ensureRoot(dirname6(destination), true);
3181
+ await checkedPath(root2, basename2(destination), false);
3182
+ const response = await client.media(`/v1/library/${encodeURIComponent(argument)}/media/${hash2}`);
1912
3183
  const reader = response.body?.getReader();
1913
3184
  if (!reader) throw new Error("Media body missing.");
1914
3185
  const chunks = [];
@@ -1924,9 +3195,9 @@ async function main() {
1924
3195
  chunks.push(value);
1925
3196
  }
1926
3197
  const bytes = Buffer.concat(chunks);
1927
- if (await sha256(bytes) !== hash) throw new CliError("HASH_MISMATCH", "Reference media verification failed.", "Do not use this file as evidence.");
1928
- await atomicWrite(root2, basename(destination), bytes, { document: false, overwrite: false });
1929
- output({ videoId: argument, path: destination, sha256: hash, bytes: size, mimeType: response.headers.get("Content-Type"), reviewed: false });
3198
+ if (await sha256(bytes) !== hash2) throw new CliError("HASH_MISMATCH", "Reference media verification failed.", "Do not use this file as evidence.");
3199
+ await atomicWrite(root2, basename2(destination), bytes, { document: false, overwrite: false });
3200
+ output({ videoId: argument, path: destination, sha256: hash2, bytes: size, mimeType: response.headers.get("Content-Type"), reviewed: false });
1930
3201
  return;
1931
3202
  }
1932
3203
  throw new Error("Use library search, library get, or library media <video-id> --sha256 hash --out file.");
@@ -1961,7 +3232,7 @@ async function main() {
1961
3232
  const adapter = str(args, "adapter") ?? "neutral";
1962
3233
  if (!["neutral", "hyperframes", "remotion"].includes(adapter)) throw new Error("Adapter must be neutral, hyperframes or remotion");
1963
3234
  const generated = exportProject(resolved, adapter);
1964
- const destination = resolve4(root, str(args, "out") ?? `videos/${project}/plans/${adapter}`);
3235
+ const destination = resolve6(root, str(args, "out") ?? `videos/${project}/plans/${adapter}`);
1965
3236
  const outRoot = await ensureRoot(destination, true);
1966
3237
  for (const [path, content] of Object.entries(generated)) await atomicWrite(outRoot, path, content, { document: false });
1967
3238
  output({ adapter, kind: "editorial_handoff", rendered: false, directory: outRoot, files: Object.keys(generated), warnings: resolved.warnings });