@ogygie/studio 0.4.0 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/dist/server.js +904 -0
  2. package/package.json +14 -14
  3. package/src/index.ts +0 -41
  4. package/src/server/app.ts +0 -84
  5. package/src/server/routes.test.ts +0 -164
  6. package/src/server/routes.ts +0 -87
  7. package/src/server/schemas.ts +0 -92
  8. package/src/server/service.test.ts +0 -305
  9. package/src/server/service.ts +0 -426
  10. package/src/server/start.ts +0 -80
  11. package/src/server/storage/local-adapter.test.ts +0 -52
  12. package/src/server/storage/local-adapter.ts +0 -67
  13. package/src/server/storage/memory-adapter.ts +0 -49
  14. package/src/server/storage/spec-storage.ts +0 -22
  15. package/src/ui/app.css +0 -3
  16. package/src/ui/features/manifest-view/ManifestView.test.tsx +0 -67
  17. package/src/ui/features/manifest-view/ManifestView.tsx +0 -80
  18. package/src/ui/features/spec-board/SpecBoard.test.tsx +0 -92
  19. package/src/ui/features/spec-board/SpecBoard.tsx +0 -198
  20. package/src/ui/features/spec-editor/MarkdownPreview.tsx +0 -49
  21. package/src/ui/features/spec-editor/SpecEditor.test.tsx +0 -79
  22. package/src/ui/features/spec-editor/SpecEditor.tsx +0 -237
  23. package/src/ui/features/spec-editor/frontmatter-fields.test.ts +0 -54
  24. package/src/ui/features/spec-editor/frontmatter-fields.ts +0 -85
  25. package/src/ui/features/spec-editor/markdown-blocks.test.ts +0 -37
  26. package/src/ui/features/spec-editor/markdown-blocks.ts +0 -82
  27. package/src/ui/index.html +0 -12
  28. package/src/ui/lib/fetchContract.ts +0 -40
  29. package/src/ui/lib/queries.ts +0 -69
  30. package/src/ui/main.tsx +0 -27
  31. package/src/ui/patterns/EmptyState.tsx +0 -24
  32. package/src/ui/patterns/ErrorBanner.tsx +0 -51
  33. package/src/ui/patterns/Modal.tsx +0 -38
  34. package/src/ui/router.tsx +0 -76
  35. package/src/ui/test-utils.tsx +0 -56
package/dist/server.js ADDED
@@ -0,0 +1,904 @@
1
+ // src/server/start.ts
2
+ import { existsSync } from "node:fs";
3
+ import { spawn } from "node:child_process";
4
+ import { join as join2 } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+
7
+ // ../ogygie-core/src/error.schema.ts
8
+ import { z } from "zod";
9
+ var OgygieErrorSchema = z.object({
10
+ ogygie: z.literal(true).describe("Marker: lets an agent tell our errors from noise"),
11
+ code: z.string().regex(/^[A-Z][A-Z0-9_]+$/, "SCREAMING_SNAKE_CASE, stable over time"),
12
+ message: z.string().min(10).describe("Human-readable sentence (technical English)"),
13
+ file: z.string().nullable().describe("Relative path of the file involved, null when not localizable"),
14
+ cause: z.string().describe("The precise datum or condition that triggered the error"),
15
+ suggestedFix: z.string().describe("Concrete action to fix \u2014 never empty"),
16
+ details: z.record(z.string(), z.unknown()).default({}),
17
+ timestamp: z.iso.datetime()
18
+ });
19
+ function ogygieError(input) {
20
+ return OgygieErrorSchema.parse({
21
+ ogygie: true,
22
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
23
+ details: {},
24
+ ...input
25
+ });
26
+ }
27
+ var OgygieFailure = class extends Error {
28
+ error;
29
+ httpStatus;
30
+ constructor(error, httpStatus = 500) {
31
+ super(error.message);
32
+ this.name = "OgygieFailure";
33
+ this.error = error;
34
+ this.httpStatus = httpStatus;
35
+ }
36
+ };
37
+ function throwOgygieError(input, httpStatus = 500) {
38
+ throw new OgygieFailure(ogygieError(input), httpStatus);
39
+ }
40
+
41
+ // src/server/storage/local-adapter.ts
42
+ import { mkdir, readdir, readFile, writeFile } from "node:fs/promises";
43
+ import { dirname, join, normalize } from "node:path";
44
+ function resolveInsideRoot(rootDir, relativePath) {
45
+ const absolute = normalize(join(rootDir, relativePath));
46
+ if (!absolute.startsWith(normalize(rootDir))) {
47
+ throwOgygieError(
48
+ {
49
+ code: "STORAGE_PATH_OUTSIDE_ROOT",
50
+ message: "The requested path escapes the repo root.",
51
+ file: null,
52
+ cause: `relative path "${relativePath}" resolves outside "${rootDir}"`,
53
+ suggestedFix: "Only address files inside the repo (specs/, ogygie.manifest.json)."
54
+ },
55
+ 400
56
+ );
57
+ }
58
+ return absolute;
59
+ }
60
+ function createLocalSpecStorage(rootDir) {
61
+ return {
62
+ async list(dir) {
63
+ const absolute = resolveInsideRoot(rootDir, dir);
64
+ try {
65
+ const entries = await readdir(absolute, { withFileTypes: true });
66
+ return entries.filter((entry) => entry.isFile()).map((entry) => ({ name: entry.name }));
67
+ } catch {
68
+ return [];
69
+ }
70
+ },
71
+ async read(path) {
72
+ const absolute = resolveInsideRoot(rootDir, path);
73
+ try {
74
+ return await readFile(absolute, "utf8");
75
+ } catch {
76
+ return throwOgygieError({
77
+ code: "STORAGE_FILE_NOT_FOUND",
78
+ message: "The requested file cannot be read from the repo.",
79
+ file: path,
80
+ cause: `readFile failed for "${absolute}"`,
81
+ suggestedFix: "Check that the studio was launched from an Ogygie repo root (specs/ + ogygie.manifest.json)."
82
+ });
83
+ }
84
+ },
85
+ async write(path, content, _message) {
86
+ const absolute = resolveInsideRoot(rootDir, path);
87
+ await mkdir(dirname(absolute), { recursive: true });
88
+ await writeFile(absolute, content, "utf8");
89
+ }
90
+ };
91
+ }
92
+
93
+ // src/server/app.ts
94
+ import Fastify from "fastify";
95
+ import {
96
+ hasZodFastifySchemaValidationErrors,
97
+ serializerCompiler,
98
+ validatorCompiler
99
+ } from "fastify-type-provider-zod";
100
+
101
+ // ../ogygie-core/src/spec.schema.ts
102
+ import { z as z3 } from "zod";
103
+
104
+ // ../ogygie-core/src/manifest.schema.ts
105
+ import { z as z2 } from "zod";
106
+ var FeatureNameSchema = z2.string().regex(/^[a-z][a-z0-9]*(-[a-z0-9]+)*$/, "kebab-case required");
107
+ var HttpRouteSchema = z2.object({
108
+ method: z2.enum(["GET", "POST", "PUT", "PATCH", "DELETE"]),
109
+ path: z2.string().startsWith("/api/"),
110
+ description: z2.string().min(10),
111
+ schemaInput: z2.string().describe("Exported name of the input Zod schema, e.g. CreateOrderInputSchema"),
112
+ schemaOutput: z2.string().describe("Exported name of the output Zod schema"),
113
+ invalidates: z2.array(z2.string()).optional().describe(
114
+ "Cache roots this mutation stales BEYOND its own path root (cross-feature dependency, read by the manifest-derived client \u2014 never guessed in a screen). Each value must be an existing feature name or route root"
115
+ )
116
+ });
117
+ var DbTableDeclarationSchema = z2.union([
118
+ z2.string(),
119
+ z2.object({ name: z2.string(), shared: z2.literal(true) })
120
+ ]);
121
+ var FeatureManifestSchema = z2.object({
122
+ name: FeatureNameSchema,
123
+ intent: z2.string().min(20).max(300).describe("The feature's 'why' in 1-3 sentences"),
124
+ app: z2.string().optional().describe("Name of the owning app (apps[].name); defaults to the api app"),
125
+ folder: z2.string().describe("Path relative to the app root, e.g. src/features/orders"),
126
+ spec: z2.string().nullable().describe("Path to the spec, e.g. specs/orders.spec.md, null for technical features"),
127
+ routes: z2.array(HttpRouteSchema),
128
+ dbTables: z2.array(DbTableDeclarationSchema).describe(
129
+ "Drizzle tables owned by this feature (one table = one owner). String = tenant-scoped; { name, shared: true } = shared referential"
130
+ ),
131
+ dependsOn: z2.array(FeatureNameSchema).describe("Features imported through their index.ts \u2014 nothing else"),
132
+ status: z2.enum(["skeleton", "in-progress", "delivered"])
133
+ });
134
+ var OgygieVersionSchema = z2.string().regex(/^\d+\.\d+\.\d+$/, "expected a plain x.y.z version");
135
+ var FrameworkManifestSchema = z2.object({
136
+ packages: z2.array(
137
+ z2.object({
138
+ name: z2.string().describe("Published npm name, e.g. @ogygie/core"),
139
+ path: z2.string().describe("Folder relative to the repo root"),
140
+ intent: z2.string().min(20)
141
+ })
142
+ ),
143
+ templates: z2.object({
144
+ project: z2.string().describe("Folder copied by `ogygie init`"),
145
+ feature: z2.string().describe("Reserved for `ogygie generate feature` (not wired yet)")
146
+ }),
147
+ governedFiles: z2.string().describe("Module declaring the exhaustive list of files `upgrade` may overwrite (D3)")
148
+ });
149
+ var VocabularyEntrySchema = z2.object({
150
+ canonical: z2.string().regex(/^[a-z][a-zA-Z0-9]*$/, "camelCase English term required").describe("The one machine term for this concept, e.g. gadget"),
151
+ forbiddenAliases: z2.array(
152
+ z2.string().min(2).regex(/^[a-z][a-z0-9]*(-[a-z0-9]+)*$/, "lowercase root (kebab-case allowed) required")
153
+ ).min(1).describe("Roots the code may never carry for this concept, e.g. widget"),
154
+ reference: z2.string().optional().describe("Anchor into AGENTS.product.md where the concept is explained")
155
+ });
156
+ var ProductVocabularySchema = z2.object({
157
+ entries: z2.array(VocabularyEntrySchema)
158
+ });
159
+ var OgygieManifestSchema = z2.object({
160
+ version: z2.literal(1),
161
+ product: z2.object({
162
+ name: z2.string(),
163
+ description: z2.string().min(20)
164
+ }),
165
+ ogygieVersion: OgygieVersionSchema.optional(),
166
+ framework: FrameworkManifestSchema.optional(),
167
+ apps: z2.array(
168
+ z2.object({
169
+ name: z2.string(),
170
+ path: z2.string(),
171
+ type: z2.enum(["api", "front", "gateway"]),
172
+ entryPoint: z2.string(),
173
+ /**
174
+ * Code the product HOSTS without maintaining it — imported as-is from
175
+ * elsewhere, the framework's own dev tooling being the typical case.
176
+ * A vendored app is skipped by the vocabulary gate: a product's business
177
+ * vocabulary has no claim over code it will never rename.
178
+ *
179
+ * A literal `true`, like `shared` on a table: an app is either vendored
180
+ * (and says so) or the product's own (and stays silent) — no third
181
+ * state. The derogation is declared here, machine-readably, and the gate
182
+ * ANNOUNCES it on every run — one that stops being visible is one that
183
+ * gets forgotten.
184
+ */
185
+ vendored: z2.literal(true).optional()
186
+ })
187
+ ),
188
+ features: z2.array(FeatureManifestSchema),
189
+ vocabulary: ProductVocabularySchema.optional().describe(
190
+ "The product's closed vocabulary \u2014 declared here, enforced by `ogygie verify-vocabulary`. Absent = gate silent"
191
+ ),
192
+ conventions: z2.object({
193
+ agentsMd: z2.literal("AGENTS.md"),
194
+ failuresMd: z2.literal("FAILURES.md"),
195
+ specsFolder: z2.literal("specs/")
196
+ })
197
+ }).superRefine((manifest, context) => {
198
+ const canonicalOwners = /* @__PURE__ */ new Map();
199
+ const aliasOwners = /* @__PURE__ */ new Map();
200
+ (manifest.vocabulary?.entries ?? []).forEach((entry, index) => {
201
+ const canonicalRoot = entry.canonical.toLowerCase();
202
+ const firstOwner = canonicalOwners.get(canonicalRoot);
203
+ if (firstOwner !== void 0) {
204
+ context.addIssue({
205
+ code: "custom",
206
+ path: ["vocabulary", "entries", index, "canonical"],
207
+ message: `canonical "${entry.canonical}" is already declared by entry ${firstOwner}`
208
+ });
209
+ } else {
210
+ canonicalOwners.set(canonicalRoot, index);
211
+ }
212
+ });
213
+ (manifest.vocabulary?.entries ?? []).forEach((entry, index) => {
214
+ entry.forbiddenAliases.forEach((alias, aliasIndex) => {
215
+ const path = ["vocabulary", "entries", index, "forbiddenAliases", aliasIndex];
216
+ const canonicalOwner = canonicalOwners.get(alias);
217
+ if (canonicalOwner !== void 0) {
218
+ context.addIssue({
219
+ code: "custom",
220
+ path,
221
+ message: `alias "${alias}" is the canonical term of entry ${canonicalOwner} \u2014 a term cannot be both`
222
+ });
223
+ return;
224
+ }
225
+ const firstOwner = aliasOwners.get(alias);
226
+ if (firstOwner !== void 0) {
227
+ context.addIssue({
228
+ code: "custom",
229
+ path,
230
+ message: `alias "${alias}" is already forbidden by entry ${firstOwner} \u2014 one alias belongs to one concept`
231
+ });
232
+ return;
233
+ }
234
+ aliasOwners.set(alias, index);
235
+ });
236
+ });
237
+ const appsByName = new Map(manifest.apps.map((app) => [app.name, app]));
238
+ const knownInvalidationTargets = new Set(
239
+ manifest.features.map((feature) => feature.name)
240
+ );
241
+ for (const feature of manifest.features) {
242
+ for (const route of feature.routes) {
243
+ const root = route.path.split("/")[2];
244
+ if (root !== void 0) knownInvalidationTargets.add(root);
245
+ }
246
+ }
247
+ manifest.features.forEach((feature, index) => {
248
+ feature.routes.forEach((route, routeIndex) => {
249
+ for (const target of route.invalidates ?? []) {
250
+ if (!knownInvalidationTargets.has(target)) {
251
+ context.addIssue({
252
+ code: "custom",
253
+ path: ["features", index, "routes", routeIndex, "invalidates"],
254
+ message: `invalidates target "${target}" matches no feature name and no route root`
255
+ });
256
+ }
257
+ }
258
+ });
259
+ });
260
+ manifest.features.forEach((feature, index) => {
261
+ if (feature.app === void 0) return;
262
+ const owningApp = appsByName.get(feature.app);
263
+ if (owningApp === void 0) {
264
+ context.addIssue({
265
+ code: "custom",
266
+ path: ["features", index, "app"],
267
+ message: `app "${feature.app}" is not declared in apps[]`
268
+ });
269
+ return;
270
+ }
271
+ if (owningApp.type !== "api" && (feature.routes.length > 0 || feature.dbTables.length > 0)) {
272
+ context.addIssue({
273
+ code: "custom",
274
+ path: ["features", index],
275
+ message: "a non-api feature owns no HTTP routes and no DB tables \u2014 declare them on the api feature"
276
+ });
277
+ }
278
+ });
279
+ });
280
+
281
+ // ../ogygie-core/src/spec.schema.ts
282
+ var DeferredBehaviorSchema = z3.string().regex(
283
+ /^.+ blockedBy:[a-z][a-z0-9]*(-[a-z0-9]+)*$/,
284
+ 'Expected "<behavior> blockedBy:<feature-name>" (feature name in kebab-case)'
285
+ );
286
+ var SpecFrontmatterSchema = z3.object({
287
+ id: FeatureNameSchema.describe("Identical to the feature name in the manifest"),
288
+ status: z3.enum(["draft", "validated", "implemented"]),
289
+ depends_on: z3.array(FeatureNameSchema),
290
+ priority: z3.enum(["P0", "P1", "P2"]),
291
+ refs: z3.array(z3.string()).default([]).describe("References to the domain model, e.g. 'modele-gally \xA74.2'"),
292
+ deferred: z3.array(DeferredBehaviorSchema).default([]).describe("Behaviors deferred to a later feature (DECISIONS.md D13)")
293
+ });
294
+ var SECTIONS_SPEC_OBLIGATOIRES = [
295
+ "# @intent",
296
+ "# Comportements",
297
+ "# Contrats de donn\xE9es",
298
+ "# \xC9tats UI",
299
+ "# Non-objectifs",
300
+ "# Budget"
301
+ ];
302
+ function splitInlineArrayElements(inner) {
303
+ const elements = [];
304
+ let current = "";
305
+ let openQuote = null;
306
+ for (const character of inner) {
307
+ if (openQuote === null && (character === '"' || character === "'")) {
308
+ openQuote = character;
309
+ current += character;
310
+ } else if (character === openQuote) {
311
+ openQuote = null;
312
+ current += character;
313
+ } else if (openQuote === null && character === ",") {
314
+ elements.push(current);
315
+ current = "";
316
+ } else {
317
+ current += character;
318
+ }
319
+ }
320
+ elements.push(current);
321
+ return { elements, unbalancedQuote: openQuote !== null };
322
+ }
323
+ function unquote(raw, location, problems) {
324
+ const trimmed = raw.trim();
325
+ const first = trimmed[0];
326
+ if (first === '"' || first === "'") {
327
+ if (trimmed.length < 2 || !trimmed.endsWith(first)) {
328
+ problems.push(`${location}: unbalanced quotes in ${JSON.stringify(trimmed)}`);
329
+ return null;
330
+ }
331
+ return trimmed.slice(1, -1);
332
+ }
333
+ return trimmed;
334
+ }
335
+ function parseSpecFrontmatter(text) {
336
+ const value = {};
337
+ const problems = [];
338
+ const lines = text.split("\n");
339
+ for (let index = 0; index < lines.length; index += 1) {
340
+ const trimmed = (lines[index] ?? "").trim();
341
+ if (trimmed === "" || trimmed.startsWith("#")) continue;
342
+ const colonIndex = trimmed.indexOf(":");
343
+ if (colonIndex === -1 || trimmed.startsWith('"') || trimmed.startsWith("'")) {
344
+ problems.push(
345
+ `unparseable line ${JSON.stringify(trimmed)}: expected \`key: value\` \u2014 a spilled array element outside any array? (scalars and arrays of scalars only, D13)`
346
+ );
347
+ continue;
348
+ }
349
+ const key = trimmed.slice(0, colonIndex).trim();
350
+ let rawValue = trimmed.slice(colonIndex + 1).trim();
351
+ if (rawValue.startsWith("[")) {
352
+ let closed = rawValue.endsWith("]");
353
+ while (!closed && index + 1 < lines.length) {
354
+ index += 1;
355
+ rawValue = `${rawValue} ${(lines[index] ?? "").trim()}`;
356
+ closed = rawValue.endsWith("]");
357
+ }
358
+ if (!closed) {
359
+ problems.push(
360
+ `key "${key}": array opened with "[" and never closed with "]" before the end of the frontmatter \u2014 a truncated array would be silently read as empty, hence refused (D13)`
361
+ );
362
+ continue;
363
+ }
364
+ const { elements, unbalancedQuote } = splitInlineArrayElements(
365
+ rawValue.slice(1, -1)
366
+ );
367
+ if (unbalancedQuote) {
368
+ problems.push(`key "${key}": unbalanced quotes inside the array`);
369
+ continue;
370
+ }
371
+ const parsedElements = [];
372
+ let broken = false;
373
+ for (const element of elements) {
374
+ if (element.trim() === "") continue;
375
+ const unquoted = unquote(element, `key "${key}"`, problems);
376
+ if (unquoted === null) broken = true;
377
+ else parsedElements.push(unquoted);
378
+ }
379
+ if (!broken) value[key] = parsedElements;
380
+ } else {
381
+ const unquoted = unquote(rawValue, `key "${key}"`, problems);
382
+ if (unquoted !== null) value[key] = unquoted;
383
+ }
384
+ }
385
+ return { value, problems };
386
+ }
387
+
388
+ // src/server/service.ts
389
+ var SPECS_DIR = "specs";
390
+ var TEMPLATE_PATH = "specs/_TEMPLATE.spec.md";
391
+ var MANIFEST_PATH = "ogygie.manifest.json";
392
+ var FRONTMATTER_BLOCK = /^---\n([\s\S]*?)\n---/;
393
+ function splitSpecFile(content) {
394
+ const match = FRONTMATTER_BLOCK.exec(content);
395
+ if (match === null || match[1] === void 0) return null;
396
+ return { frontmatterText: match[1], body: content.slice(match[0].length) };
397
+ }
398
+ function presentSectionsOf(body) {
399
+ const searchable = `
400
+ ${body}`;
401
+ const present = [];
402
+ let cursor = 0;
403
+ for (const section of SECTIONS_SPEC_OBLIGATOIRES) {
404
+ const position = searchable.indexOf(`
405
+ ${section}`, cursor);
406
+ if (position === -1) continue;
407
+ present.push(section);
408
+ cursor = position + section.length;
409
+ }
410
+ return present;
411
+ }
412
+ function missingSectionsOf(body) {
413
+ const present = new Set(presentSectionsOf(body));
414
+ return SECTIONS_SPEC_OBLIGATOIRES.filter((section) => !present.has(section));
415
+ }
416
+ function quoteFrontmatterElement(value, specPath) {
417
+ if (value.includes("\n") || value.includes('"') && value.includes("'")) {
418
+ throwOgygieError(
419
+ {
420
+ code: "SPEC_FRONTMATTER_UNSERIALIZABLE",
421
+ message: "A frontmatter array element cannot be written in the restricted spec format.",
422
+ file: specPath,
423
+ cause: `element ${JSON.stringify(value)} contains a newline or both quote kinds`,
424
+ suggestedFix: "Rewrite the element without newlines and with at most one kind of quote character."
425
+ },
426
+ 422
427
+ );
428
+ }
429
+ return value.includes('"') ? `'${value}'` : `"${value}"`;
430
+ }
431
+ function serializeSpecFrontmatter(frontmatter, specPath) {
432
+ const quote = (value) => quoteFrontmatterElement(value, specPath);
433
+ const lines = [
434
+ `id: ${frontmatter.id}`,
435
+ `status: ${frontmatter.status}`,
436
+ `depends_on: [${frontmatter.depends_on.join(", ")}]`,
437
+ `priority: ${frontmatter.priority}`,
438
+ `refs: [${frontmatter.refs.map(quote).join(", ")}]`
439
+ ];
440
+ if (frontmatter.deferred.length > 0) {
441
+ lines.push(`deferred: [${frontmatter.deferred.map(quote).join(", ")}]`);
442
+ }
443
+ return lines.join("\n");
444
+ }
445
+ function specPathOf(specId) {
446
+ return `${SPECS_DIR}/${specId}.spec.md`;
447
+ }
448
+ function frontmatterInvalidError(filePath, cause) {
449
+ return ogygieError({
450
+ code: "SPEC_FRONTMATTER_INVALID",
451
+ message: `Spec ${filePath} has an invalid frontmatter.`,
452
+ file: filePath,
453
+ cause,
454
+ suggestedFix: "Fix the frontmatter in your editor to match packages/ogygie-core/src/spec.schema.ts \u2014 the studio never repairs a spec silently."
455
+ });
456
+ }
457
+ function parseSpec(filePath, fileName, content) {
458
+ const split = splitSpecFile(content);
459
+ if (split === null) {
460
+ return {
461
+ ok: false,
462
+ error: frontmatterInvalidError(
463
+ filePath,
464
+ "file does not start with a --- ... --- frontmatter block"
465
+ )
466
+ };
467
+ }
468
+ const parsed = parseSpecFrontmatter(split.frontmatterText);
469
+ if (parsed.problems.length > 0) {
470
+ return { ok: false, error: frontmatterInvalidError(filePath, parsed.problems.join(" ; ")) };
471
+ }
472
+ const checked = SpecFrontmatterSchema.safeParse(parsed.value);
473
+ if (!checked.success) {
474
+ return {
475
+ ok: false,
476
+ error: frontmatterInvalidError(
477
+ filePath,
478
+ checked.error.issues.map((issue) => `${issue.path.join(".")}: ${issue.message}`).join(" ; ")
479
+ )
480
+ };
481
+ }
482
+ const expectedId = fileName.replace(/\.spec\.md$/, "");
483
+ if (checked.data.id !== expectedId) {
484
+ return {
485
+ ok: false,
486
+ error: ogygieError({
487
+ code: "SPEC_ID_MISMATCH",
488
+ message: `Spec ${fileName} declares id "${checked.data.id}" \u2014 expected "${expectedId}".`,
489
+ file: filePath,
490
+ cause: `id=${checked.data.id} \u2260 filename ${expectedId}`,
491
+ suggestedFix: "Make the frontmatter id identical to the file name."
492
+ })
493
+ };
494
+ }
495
+ return { ok: true, spec: { frontmatter: checked.data, body: split.body } };
496
+ }
497
+ function toReadableSummary(filePath, spec) {
498
+ return {
499
+ id: spec.frontmatter.id,
500
+ status: spec.frontmatter.status,
501
+ priority: spec.frontmatter.priority,
502
+ depends_on: spec.frontmatter.depends_on,
503
+ filePath,
504
+ presentSections: presentSectionsOf(spec.body)
505
+ };
506
+ }
507
+ async function listSpecFileNames(deps) {
508
+ const entries = await deps.storage.list(SPECS_DIR);
509
+ return entries.map((entry) => entry.name).filter((name) => name.endsWith(".spec.md") && !name.startsWith("_")).sort();
510
+ }
511
+ async function listSpecSummaries(deps) {
512
+ const fileNames = await listSpecFileNames(deps);
513
+ const summaries = [];
514
+ for (const fileName of fileNames) {
515
+ const filePath = `${SPECS_DIR}/${fileName}`;
516
+ const content = await deps.storage.read(filePath);
517
+ const parsed = parseSpec(filePath, fileName, content);
518
+ const id = fileName.replace(/\.spec\.md$/, "");
519
+ summaries.push(
520
+ parsed.ok ? toReadableSummary(filePath, parsed.spec) : { id, filePath, error: parsed.error }
521
+ );
522
+ }
523
+ return summaries;
524
+ }
525
+ async function readParsedSpec(deps, specId) {
526
+ const fileName = `${specId}.spec.md`;
527
+ const fileNames = await listSpecFileNames(deps);
528
+ if (!fileNames.includes(fileName)) {
529
+ throwOgygieError(
530
+ {
531
+ code: "SPEC_NOT_FOUND",
532
+ message: `No spec file exists for id "${specId}".`,
533
+ file: specPathOf(specId),
534
+ cause: `${fileName} absent from ${SPECS_DIR}/`,
535
+ suggestedFix: "Pick an id from GET /api/specs, or create the spec first."
536
+ },
537
+ 404
538
+ );
539
+ }
540
+ const filePath = specPathOf(specId);
541
+ const content = await deps.storage.read(filePath);
542
+ const parsed = parseSpec(filePath, fileName, content);
543
+ if (!parsed.ok) {
544
+ throw new OgygieFailure(parsed.error, 422);
545
+ }
546
+ return parsed.spec;
547
+ }
548
+ async function getSpec(deps, specId) {
549
+ const spec = await readParsedSpec(deps, specId);
550
+ return {
551
+ frontmatter: spec.frontmatter,
552
+ body: spec.body,
553
+ presentSections: presentSectionsOf(spec.body)
554
+ };
555
+ }
556
+ async function saveSpec(deps, specId, input) {
557
+ const current = await readParsedSpec(deps, specId);
558
+ const filePath = specPathOf(specId);
559
+ if (input.frontmatter.id !== specId) {
560
+ throwOgygieError(
561
+ {
562
+ code: "SPEC_ID_MISMATCH",
563
+ message: `The submitted frontmatter declares id "${input.frontmatter.id}" \u2014 expected "${specId}".`,
564
+ file: filePath,
565
+ cause: `frontmatter.id=${input.frontmatter.id} \u2260 route param ${specId}`,
566
+ suggestedFix: "Keep the id identical to the file name \u2014 it is not editable."
567
+ },
568
+ 422
569
+ );
570
+ }
571
+ const from = current.frontmatter.status;
572
+ const target = input.frontmatter.status;
573
+ if (target === "implemented" && from !== "implemented") {
574
+ throwOgygieError(
575
+ {
576
+ code: "STUDIO_TRANSITION_FORBIDDEN",
577
+ message: "Status `implemented` belongs to the implementation flow (manifest + CI), never to the studio UI.",
578
+ file: filePath,
579
+ cause: `attempted transition ${from} \u2192 implemented from the studio`,
580
+ suggestedFix: "Implement the feature, update the manifest to delivered, then flip the spec in the implementation commit."
581
+ },
582
+ 409
583
+ );
584
+ }
585
+ const isValidation = from === "draft" && target === "validated";
586
+ if (from !== target && !isValidation) {
587
+ throwOgygieError(
588
+ {
589
+ code: "STUDIO_TRANSITION_FORBIDDEN",
590
+ message: "The studio only performs the draft \u2192 validated transition.",
591
+ file: filePath,
592
+ cause: `attempted transition ${from} \u2192 ${target} from the studio`,
593
+ suggestedFix: "Reopening or downgrading a spec happens in the implementation flow (spec amendment commit), not in the studio."
594
+ },
595
+ 409
596
+ );
597
+ }
598
+ let body = input.body ?? current.body;
599
+ if (input.body !== void 0 && !body.startsWith("\n")) {
600
+ body = `
601
+ ${body}`;
602
+ }
603
+ if (isValidation) {
604
+ const missing = missingSectionsOf(body);
605
+ if (missing.length > 0) {
606
+ throwOgygieError(
607
+ {
608
+ code: "SPEC_SECTION_MISSING",
609
+ message: `Spec ${specId} cannot be validated: ${missing.length} mandatory section(s) missing.`,
610
+ file: filePath,
611
+ cause: `missing or out-of-order: ${missing.join(" ; ")}`,
612
+ suggestedFix: "Write the missing sections (in canonical order) before validating \u2014 a validated spec is an implementation contract.",
613
+ details: { missingSections: missing }
614
+ },
615
+ 409
616
+ );
617
+ }
618
+ }
619
+ const serialized = serializeSpecFrontmatter(input.frontmatter, filePath);
620
+ const content = `---
621
+ ${serialized}
622
+ ---${body}`;
623
+ const message = isValidation ? `spec: validate ${specId}` : `spec: save ${specId}`;
624
+ await deps.storage.write(filePath, content, message);
625
+ return toReadableSummary(filePath, { frontmatter: input.frontmatter, body });
626
+ }
627
+ async function createSpec(deps, specId) {
628
+ const fileName = `${specId}.spec.md`;
629
+ const fileNames = await listSpecFileNames(deps);
630
+ if (fileNames.includes(fileName)) {
631
+ throwOgygieError(
632
+ {
633
+ code: "SPEC_ALREADY_EXISTS",
634
+ message: `A spec already exists for id "${specId}" \u2014 nothing was overwritten.`,
635
+ file: specPathOf(specId),
636
+ cause: `${fileName} already present in ${SPECS_DIR}/`,
637
+ suggestedFix: "Open the existing spec from the board, or pick another id."
638
+ },
639
+ 409
640
+ );
641
+ }
642
+ let template;
643
+ try {
644
+ template = await deps.storage.read(TEMPLATE_PATH);
645
+ } catch {
646
+ return throwOgygieError({
647
+ code: "SPEC_TEMPLATE_NOT_FOUND",
648
+ message: "specs/_TEMPLATE.spec.md is missing \u2014 the studio creates specs from it.",
649
+ file: TEMPLATE_PATH,
650
+ cause: "template file unreadable",
651
+ suggestedFix: "Restore specs/_TEMPLATE.spec.md from the framework skeleton."
652
+ });
653
+ }
654
+ const content = template.replace(/^id: .*$/m, `id: ${specId}`);
655
+ const filePath = specPathOf(specId);
656
+ await deps.storage.write(filePath, content, `spec: create ${specId}`);
657
+ const parsed = parseSpec(filePath, fileName, content);
658
+ if (!parsed.ok) {
659
+ return throwOgygieError({
660
+ code: "SPEC_TEMPLATE_NOT_FOUND",
661
+ message: "specs/_TEMPLATE.spec.md does not produce a readable draft spec.",
662
+ file: TEMPLATE_PATH,
663
+ cause: parsed.error.cause,
664
+ suggestedFix: "Fix the template's frontmatter to match SpecFrontmatterSchema."
665
+ });
666
+ }
667
+ return toReadableSummary(filePath, parsed.spec);
668
+ }
669
+ async function getManifest(deps) {
670
+ const content = await deps.storage.read(MANIFEST_PATH);
671
+ let raw;
672
+ try {
673
+ raw = JSON.parse(content);
674
+ } catch (parseError) {
675
+ return throwOgygieError(
676
+ {
677
+ code: "MANIFEST_INVALID",
678
+ message: "ogygie.manifest.json is not valid JSON.",
679
+ file: MANIFEST_PATH,
680
+ cause: parseError instanceof Error ? parseError.message : "unparseable JSON",
681
+ suggestedFix: "Fix the manifest JSON \u2014 the studio only ever READS it."
682
+ },
683
+ 422
684
+ );
685
+ }
686
+ const parsed = OgygieManifestSchema.safeParse(raw);
687
+ if (!parsed.success) {
688
+ throwOgygieError(
689
+ {
690
+ code: "MANIFEST_INVALID",
691
+ message: "ogygie.manifest.json does not satisfy OgygieManifestSchema.",
692
+ file: MANIFEST_PATH,
693
+ cause: parsed.error.issues.map((issue) => `${issue.path.join(".")}: ${issue.message}`).join(" ; "),
694
+ suggestedFix: "Fix the listed fields to match packages/ogygie-core/src/manifest.schema.ts."
695
+ },
696
+ 422
697
+ );
698
+ }
699
+ return parsed.data;
700
+ }
701
+
702
+ // src/server/schemas.ts
703
+ import { z as z4 } from "zod";
704
+ var SpecStatusSchema = SpecFrontmatterSchema.shape.status;
705
+ var ReadableSpecSummarySchema = z4.object({
706
+ id: FeatureNameSchema,
707
+ status: SpecStatusSchema,
708
+ priority: SpecFrontmatterSchema.shape.priority,
709
+ depends_on: z4.array(FeatureNameSchema),
710
+ filePath: z4.string(),
711
+ presentSections: z4.array(z4.string())
712
+ });
713
+ var UnreadableSpecSummarySchema = z4.object({
714
+ id: z4.string(),
715
+ filePath: z4.string(),
716
+ error: OgygieErrorSchema
717
+ });
718
+ var SpecSummarySchema = z4.union([
719
+ ReadableSpecSummarySchema,
720
+ UnreadableSpecSummarySchema
721
+ ]);
722
+ var ListSpecsOutputSchema = z4.object({
723
+ specs: z4.array(SpecSummarySchema)
724
+ });
725
+ var SpecParamsSchema = z4.object({
726
+ specId: FeatureNameSchema
727
+ });
728
+ var GetSpecOutputSchema = z4.object({
729
+ frontmatter: SpecFrontmatterSchema,
730
+ /** Markdown body after the frontmatter block, byte-exact. */
731
+ body: z4.string(),
732
+ presentSections: z4.array(z4.string())
733
+ });
734
+ var UpdateSpecInputSchema = z4.object({
735
+ frontmatter: SpecFrontmatterSchema,
736
+ /** Omitted ⇒ the existing markdown body is preserved byte-exact. */
737
+ body: z4.string().max(2e5).optional()
738
+ });
739
+ var UpdateSpecOutputSchema = z4.object({
740
+ spec: ReadableSpecSummarySchema
741
+ });
742
+ var CreateSpecInputSchema = z4.object({
743
+ id: FeatureNameSchema.max(80)
744
+ });
745
+ var CreateSpecOutputSchema = z4.object({
746
+ spec: ReadableSpecSummarySchema
747
+ });
748
+ var GetManifestOutputSchema = z4.object({
749
+ manifest: OgygieManifestSchema
750
+ });
751
+
752
+ // src/server/routes.ts
753
+ async function registerStudioRoutes(app, deps) {
754
+ const route = app.withTypeProvider();
755
+ route.route({
756
+ method: "GET",
757
+ url: "/api/specs",
758
+ schema: { response: { 200: ListSpecsOutputSchema } },
759
+ handler: async (_request, reply) => {
760
+ return reply.code(200).send({ specs: await listSpecSummaries(deps) });
761
+ }
762
+ });
763
+ route.route({
764
+ method: "GET",
765
+ url: "/api/specs/:specId",
766
+ schema: { params: SpecParamsSchema, response: { 200: GetSpecOutputSchema } },
767
+ handler: async (request, reply) => {
768
+ return reply.code(200).send(await getSpec(deps, request.params.specId));
769
+ }
770
+ });
771
+ route.route({
772
+ method: "PUT",
773
+ url: "/api/specs/:specId",
774
+ schema: {
775
+ params: SpecParamsSchema,
776
+ body: UpdateSpecInputSchema,
777
+ response: { 200: UpdateSpecOutputSchema }
778
+ },
779
+ handler: async (request, reply) => {
780
+ const spec = await saveSpec(deps, request.params.specId, request.body);
781
+ return reply.code(200).send({ spec });
782
+ }
783
+ });
784
+ route.route({
785
+ method: "POST",
786
+ url: "/api/specs",
787
+ schema: {
788
+ body: CreateSpecInputSchema,
789
+ response: { 201: CreateSpecOutputSchema }
790
+ },
791
+ handler: async (request, reply) => {
792
+ const spec = await createSpec(deps, request.body.id);
793
+ return reply.code(201).send({ spec });
794
+ }
795
+ });
796
+ route.route({
797
+ method: "GET",
798
+ url: "/api/manifest",
799
+ schema: { response: { 200: GetManifestOutputSchema } },
800
+ handler: async (_request, reply) => {
801
+ return reply.code(200).send({ manifest: await getManifest(deps) });
802
+ }
803
+ });
804
+ }
805
+
806
+ // src/server/app.ts
807
+ async function createStudioApp(options) {
808
+ const app = Fastify({ logger: false });
809
+ app.setValidatorCompiler(validatorCompiler);
810
+ app.setSerializerCompiler(serializerCompiler);
811
+ app.setErrorHandler((error, _request, reply) => {
812
+ if (error instanceof OgygieFailure) {
813
+ return reply.status(error.httpStatus).send(error.error);
814
+ }
815
+ if (hasZodFastifySchemaValidationErrors(error)) {
816
+ return reply.status(400).send(
817
+ ogygieError({
818
+ code: "CONTRACT_VALIDATION_FAILED",
819
+ message: "The request does not satisfy the route's input contract.",
820
+ file: null,
821
+ cause: error.validation.map((issue) => `${issue.instancePath || "(root)"}: ${issue.message ?? "invalid"}`).join(" ; "),
822
+ suggestedFix: "Fix the listed fields to match the route's Zod input schema."
823
+ })
824
+ );
825
+ }
826
+ app.log.error(error);
827
+ return reply.status(500).send(
828
+ ogygieError({
829
+ code: "INTERNAL_ERROR",
830
+ message: "Unexpected studio error \u2014 details are in the server logs only.",
831
+ file: null,
832
+ cause: "unhandled exception",
833
+ suggestedFix: "Relaunch `pnpm ogygie studio`; record a FAILURES.md entry if it recurs."
834
+ })
835
+ );
836
+ });
837
+ await registerStudioRoutes(app, options);
838
+ if (options.uiDistDir !== void 0) {
839
+ const fastifyStatic = (await import("@fastify/static")).default;
840
+ await app.register(fastifyStatic, { root: options.uiDistDir });
841
+ app.setNotFoundHandler((request, reply) => {
842
+ if (request.method === "GET" && !request.url.startsWith("/api/")) {
843
+ return reply.sendFile("index.html");
844
+ }
845
+ return reply.status(404).send(
846
+ ogygieError({
847
+ code: "ROUTE_NOT_FOUND",
848
+ message: "No studio route matches this request.",
849
+ file: null,
850
+ cause: `${request.method} ${request.url}`,
851
+ suggestedFix: "Use the routes documented in specs/ogygie-studio.spec.md."
852
+ })
853
+ );
854
+ });
855
+ }
856
+ await app.ready();
857
+ return app;
858
+ }
859
+
860
+ // src/server/start.ts
861
+ var PACKAGE_DIR = fileURLToPath(new URL("../..", import.meta.url));
862
+ async function buildUiIfMissing(uiDistDir) {
863
+ if (existsSync(join2(uiDistDir, "index.html"))) return;
864
+ console.log("ogygie studio \u2014 UI absente, construction unique\u2026");
865
+ try {
866
+ const { build } = await import("vite");
867
+ await build({
868
+ configFile: join2(PACKAGE_DIR, "vite.config.ts"),
869
+ logLevel: "warn"
870
+ });
871
+ } catch (thrown) {
872
+ throwOgygieError({
873
+ code: "OGYGIE_STUDIO_UI_MISSING",
874
+ message: "The studio UI is missing and cannot be built here.",
875
+ file: "packages/ogygie-studio/dist-ui/index.html",
876
+ cause: thrown instanceof Error ? `dist-ui absent and vite unavailable: ${thrown.message}` : "dist-ui absent and vite unavailable",
877
+ suggestedFix: "A published @ogygie/studio ships its UI already built \u2014 reinstall the package. Inside the framework workspace, run `pnpm --filter @ogygie/studio build`."
878
+ });
879
+ }
880
+ }
881
+ function openInBrowser(url) {
882
+ const opener = process.platform === "darwin" ? "open" : "xdg-open";
883
+ try {
884
+ spawn(opener, [url], { detached: true, stdio: "ignore" }).unref();
885
+ } catch {
886
+ }
887
+ }
888
+ async function startStudio(options) {
889
+ const port = options.port ?? 4001;
890
+ const uiDistDir = join2(PACKAGE_DIR, "dist-ui");
891
+ await buildUiIfMissing(uiDistDir);
892
+ const app = await createStudioApp({
893
+ storage: createLocalSpecStorage(options.repoRoot),
894
+ uiDistDir
895
+ });
896
+ await app.listen({ port, host: "127.0.0.1" });
897
+ const url = `http://127.0.0.1:${port}`;
898
+ console.log(`ogygie studio \u2014 ${url} (repo: ${options.repoRoot})`);
899
+ if (options.openBrowser !== false) openInBrowser(url);
900
+ return app;
901
+ }
902
+ export {
903
+ startStudio
904
+ };