@ogygie/studio 0.19.0 → 0.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/server.js CHANGED
@@ -1,5 +1,5 @@
1
1
  // src/server/start.ts
2
- import { existsSync } from "node:fs";
2
+ import { existsSync, readFileSync } from "node:fs";
3
3
  import { spawn } from "node:child_process";
4
4
  import { dirname as dirname2, join as join2 } from "node:path";
5
5
  import { fileURLToPath } from "node:url";
@@ -38,24 +38,167 @@ function throwOgygieError(input, httpStatus = 500) {
38
38
  throw new OgygieFailure(ogygieError(input), httpStatus);
39
39
  }
40
40
 
41
- // src/server/storage/local-adapter.ts
42
- import { mkdir, writeFile } from "node:fs/promises";
43
- import { dirname, isAbsolute as isAbsolute2, resolve as resolve2, sep as sep2 } from "node:path";
44
-
45
41
  // ../ogygie-core/src/repo-adapter.ts
46
42
  import { readFile as readFileFromDisk, readdir, realpath, stat } from "node:fs/promises";
47
43
  import { isAbsolute, join, resolve, sep } from "node:path";
48
44
  import { z as z2 } from "zod";
45
+
46
+ // ../ogygie-core/src/repo-adapter-github.ts
47
+ var NEVER_LISTED = /* @__PURE__ */ new Set(["node_modules"]);
48
+ function isSkippedSegment(segment) {
49
+ return segment.startsWith(".") || NEVER_LISTED.has(segment);
50
+ }
51
+ function refusePathOutsideRepo(relativePath, cause) {
52
+ return throwOgygieError(
53
+ {
54
+ code: "PATH_OUTSIDE_REPO",
55
+ message: "The requested path resolves outside the repo root.",
56
+ file: relativePath,
57
+ cause,
58
+ suggestedFix: "Address a path INSIDE the repo, written relative to its root and without `..`."
59
+ },
60
+ 400
61
+ );
62
+ }
63
+ function normalizeRepoPath(relativePath) {
64
+ if (relativePath.startsWith("/") || /^[A-Za-z]:[\\/]/.test(relativePath)) {
65
+ refusePathOutsideRepo(relativePath, `"${relativePath}" is an absolute path`);
66
+ }
67
+ const segments = relativePath.split(/[\\/]+/).filter((segment) => segment !== "" && segment !== ".");
68
+ if (segments.includes("..")) {
69
+ refusePathOutsideRepo(relativePath, `"${relativePath}" climbs out of the repo with ".."`);
70
+ }
71
+ return segments.join("/");
72
+ }
73
+ function createGithubApiRepoAdapter(options) {
74
+ const apiBase = options.apiBase ?? "https://api.github.com";
75
+ const repoUrl = `${apiBase}/repos/${options.owner}/${options.repo}`;
76
+ const where = `${options.owner}/${options.repo}@${options.ref}`;
77
+ async function request(url, allowNotFound) {
78
+ let response;
79
+ try {
80
+ response = await options.fetchImpl(url, {
81
+ method: "GET",
82
+ headers: {
83
+ accept: "application/vnd.github+json",
84
+ "x-github-api-version": "2022-11-28",
85
+ "user-agent": options.userAgent ?? "ogygie-core",
86
+ ...options.token === void 0 ? {} : { authorization: `Bearer ${options.token}` }
87
+ }
88
+ });
89
+ } catch (thrown) {
90
+ return throwOgygieError({
91
+ code: "GITHUB_UNAVAILABLE",
92
+ message: `GitHub could not be reached to read ${where}.`,
93
+ file: null,
94
+ cause: thrown instanceof Error ? thrown.message : String(thrown),
95
+ suggestedFix: "Retry once the network is back \u2014 a read has no side effect to undo."
96
+ });
97
+ }
98
+ if (response.status === 404 && allowNotFound) return null;
99
+ if (!response.ok) {
100
+ return throwOgygieError({
101
+ code: "GITHUB_UNAVAILABLE",
102
+ message: `GitHub answered ${response.status} for GET ${url.replace(apiBase, "")}.`,
103
+ file: null,
104
+ cause: `status=${response.status}`,
105
+ suggestedFix: response.status === 401 || response.status === 403 ? "Check the GitHub token: it must grant contents (read) on this repo." : "Retry later \u2014 a read has no side effect to undo."
106
+ });
107
+ }
108
+ return await response.json();
109
+ }
110
+ let commitShaPromise = null;
111
+ let treePromise = null;
112
+ function commitSha() {
113
+ commitShaPromise ??= (async () => {
114
+ const commit = await request(
115
+ `${repoUrl}/commits/${encodeURIComponent(options.ref)}`,
116
+ true
117
+ );
118
+ if (commit === null) {
119
+ return throwOgygieError({
120
+ code: "GITHUB_UNAVAILABLE",
121
+ message: `Ref "${options.ref}" does not exist on ${options.owner}/${options.repo}.`,
122
+ file: null,
123
+ cause: "the commits endpoint answered 404 for that ref",
124
+ suggestedFix: "Name an existing branch, tag or commit SHA of that repo."
125
+ });
126
+ }
127
+ return commit.sha;
128
+ })();
129
+ return commitShaPromise;
130
+ }
131
+ function tree() {
132
+ treePromise ??= (async () => {
133
+ const sha = await commitSha();
134
+ const answer = await request(
135
+ `${repoUrl}/git/trees/${sha}?recursive=1`,
136
+ false
137
+ );
138
+ if (answer === null) return [];
139
+ if (answer.truncated) {
140
+ return throwOgygieError({
141
+ code: "REPO_TREE_TRUNCATED",
142
+ message: `The tree of ${where} is too large for one GitHub answer.`,
143
+ file: null,
144
+ cause: "the trees endpoint set truncated=true",
145
+ suggestedFix: "Read that repo from a clone (createFilesystemRepoAdapter) rather than through the API."
146
+ });
147
+ }
148
+ return answer.tree.filter(
149
+ (entry) => entry.type === "blob" && !entry.path.split("/").slice(0, -1).some(isSkippedSegment)
150
+ );
151
+ })();
152
+ return treePromise;
153
+ }
154
+ return {
155
+ async readFile(relativePath) {
156
+ const path = normalizeRepoPath(relativePath);
157
+ const sha = await commitSha();
158
+ const file = await request(`${repoUrl}/contents/${path}?ref=${sha}`, true);
159
+ if (file === null || Array.isArray(file) || file.type !== "file") {
160
+ return throwOgygieError({
161
+ code: "REPO_FILE_NOT_FOUND",
162
+ message: "The requested file cannot be read from the repo.",
163
+ file: relativePath,
164
+ cause: file === null ? `no file at "${path}" in ${where}` : `"${path}" is not a file in ${where}`,
165
+ suggestedFix: "Check the path against what the map declares \u2014 the manifest is the only chart of a repo."
166
+ });
167
+ }
168
+ if (file.encoding === "base64" && file.content !== void 0) {
169
+ return Buffer.from(file.content, "base64").toString("utf8");
170
+ }
171
+ const blob = await request(`${repoUrl}/git/blobs/${file.sha}`, false);
172
+ return Buffer.from(blob?.content ?? "", "base64").toString("utf8");
173
+ },
174
+ async listDirectory(relativePath) {
175
+ const path = normalizeRepoPath(relativePath);
176
+ const prefix = path === "" ? "" : `${path}/`;
177
+ return (await tree()).filter((entry) => entry.path.startsWith(prefix)).map((entry) => ({ path: entry.path.slice(prefix.length), sizeBytes: entry.size ?? 0 })).sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0);
178
+ },
179
+ async exists(relativePath) {
180
+ const path = normalizeRepoPath(relativePath);
181
+ if (path === "") return true;
182
+ const prefix = `${path}/`;
183
+ return (await tree()).some((entry) => entry.path === path || entry.path.startsWith(prefix));
184
+ },
185
+ async currentRef() {
186
+ return commitSha();
187
+ }
188
+ };
189
+ }
190
+
191
+ // ../ogygie-core/src/repo-adapter.ts
49
192
  var RepoFileEntrySchema = z2.object({
50
193
  /** Path relative to the LISTED directory, "/" separated, never absolute. */
51
194
  path: z2.string(),
52
195
  sizeBytes: z2.number().int().nonnegative()
53
196
  });
54
- var NEVER_LISTED = /* @__PURE__ */ new Set(["node_modules"]);
197
+ var NEVER_LISTED2 = /* @__PURE__ */ new Set(["node_modules"]);
55
198
  function isSkippedDirectory(name) {
56
- return name.startsWith(".") || NEVER_LISTED.has(name);
199
+ return name.startsWith(".") || NEVER_LISTED2.has(name);
57
200
  }
58
- function refusePathOutsideRepo(relativePath, cause) {
201
+ function refusePathOutsideRepo2(relativePath, cause) {
59
202
  return throwOgygieError(
60
203
  {
61
204
  code: "PATH_OUTSIDE_REPO",
@@ -69,12 +212,12 @@ function refusePathOutsideRepo(relativePath, cause) {
69
212
  }
70
213
  function resolveInsideRoot(rootDir, relativePath) {
71
214
  if (isAbsolute(relativePath)) {
72
- refusePathOutsideRepo(relativePath, `"${relativePath}" is an absolute path`);
215
+ refusePathOutsideRepo2(relativePath, `"${relativePath}" is an absolute path`);
73
216
  }
74
217
  const root = resolve(rootDir);
75
218
  const absolute = resolve(root, relativePath);
76
219
  if (absolute !== root && !absolute.startsWith(root + sep)) {
77
- refusePathOutsideRepo(relativePath, `"${relativePath}" resolves to "${absolute}", outside "${root}"`);
220
+ refusePathOutsideRepo2(relativePath, `"${relativePath}" resolves to "${absolute}", outside "${root}"`);
78
221
  }
79
222
  return absolute;
80
223
  }
@@ -88,7 +231,7 @@ async function assertRealPathInsideRoot(rootDir, absolute, relativePath) {
88
231
  return;
89
232
  }
90
233
  if (realTarget !== realRoot && !realTarget.startsWith(realRoot + sep)) {
91
- refusePathOutsideRepo(
234
+ refusePathOutsideRepo2(
92
235
  relativePath,
93
236
  `"${relativePath}" is a symbolic link leading to "${realTarget}", outside "${realRoot}"`
94
237
  );
@@ -204,43 +347,60 @@ function createFilesystemRepoAdapter(rootDir) {
204
347
  };
205
348
  }
206
349
 
207
- // src/server/storage/local-adapter.ts
208
- function resolveInsideRoot2(rootDir, relativePath) {
209
- const root = resolve2(rootDir);
210
- const absolute = resolve2(root, relativePath);
211
- if (isAbsolute2(relativePath) || absolute !== root && !absolute.startsWith(root + sep2)) {
212
- throwOgygieError(
213
- {
214
- code: "PATH_OUTSIDE_REPO",
215
- message: "The requested path escapes the repo root.",
216
- file: relativePath,
217
- cause: `relative path "${relativePath}" resolves to "${absolute}", outside "${root}"`,
218
- suggestedFix: "Only address files inside the repo (specs/, ogygie.manifest.json)."
219
- },
220
- 400
221
- );
350
+ // ../ogygie-core/src/proposal.ts
351
+ import { z as z5 } from "zod";
352
+
353
+ // ../ogygie-core/src/decision-refs.ts
354
+ var DECISION_REFERENCE = /(?<![A-Za-z0-9])D(\d+)(?![A-Za-z0-9])/g;
355
+ var DECISION_HEADING = /^##\s+D(\d+)\b/;
356
+ function parseKnownDecisionIds(decisionsMarkdown) {
357
+ const ids = [];
358
+ for (const line of decisionsMarkdown.split("\n")) {
359
+ const heading = DECISION_HEADING.exec(line);
360
+ if (heading !== null) ids.push(`D${heading[1]}`);
222
361
  }
223
- return absolute;
362
+ return ids;
224
363
  }
225
- function createLocalSpecStorage(rootDir) {
226
- const reader = createFilesystemRepoAdapter(rootDir);
227
- return {
228
- ...reader,
229
- async write(path, content, _message) {
230
- const absolute = resolveInsideRoot2(rootDir, path);
231
- await mkdir(dirname(absolute), { recursive: true });
232
- await writeFile(absolute, content, "utf8");
233
- }
364
+ function extractDecisionSection(decisionsMarkdown, decisionId) {
365
+ const lines = decisionsMarkdown.split("\n");
366
+ const trimTrailingBlanks = (section) => {
367
+ while (section.length > 0 && (section[section.length - 1] ?? "").trim() === "") section.pop();
368
+ return section.join("\n");
234
369
  };
370
+ let start = -1;
371
+ for (let index = 0; index < lines.length; index += 1) {
372
+ const heading = DECISION_HEADING.exec(lines[index] ?? "");
373
+ if (heading === null) continue;
374
+ if (start === -1) {
375
+ if (`D${heading[1]}` === decisionId) start = index;
376
+ continue;
377
+ }
378
+ return trimTrailingBlanks(lines.slice(start, index));
379
+ }
380
+ return start === -1 ? null : trimTrailingBlanks(lines.slice(start));
381
+ }
382
+ function findDecisionReferences(text) {
383
+ const references = [];
384
+ const lines = text.split("\n");
385
+ for (let index = 0; index < lines.length; index += 1) {
386
+ const line = lines[index] ?? "";
387
+ for (const match of line.matchAll(DECISION_REFERENCE)) {
388
+ references.push({ decision: `D${match[1]}`, line: index + 1 });
389
+ }
390
+ }
391
+ return references;
392
+ }
393
+ function findUnknownDecisionReferences(text, knownDecisionIds) {
394
+ const known = new Set(knownDecisionIds);
395
+ const unknown = [];
396
+ const alreadyReported = /* @__PURE__ */ new Set();
397
+ for (const reference of findDecisionReferences(text)) {
398
+ if (known.has(reference.decision) || alreadyReported.has(reference.decision)) continue;
399
+ alreadyReported.add(reference.decision);
400
+ unknown.push(reference);
401
+ }
402
+ return unknown;
235
403
  }
236
-
237
- // src/server/app.ts
238
- import Fastify from "fastify";
239
- import {
240
- hasZodFastifySchemaValidationErrors,
241
- serializerCompiler,
242
- validatorCompiler
243
- } from "fastify-type-provider-zod";
244
404
 
245
405
  // ../ogygie-core/src/spec.schema.ts
246
406
  import { z as z4 } from "zod";
@@ -631,131 +791,556 @@ function parseSpecFrontmatter(text) {
631
791
  }
632
792
  return { value, problems };
633
793
  }
634
-
635
- // src/server/service.ts
636
- var SPECS_DIR = "specs";
637
- var TEMPLATE_PATH = "specs/_TEMPLATE.spec.md";
638
- var MANIFEST_PATH = "ogygie.manifest.json";
639
794
  var FRONTMATTER_BLOCK = /^---\n([\s\S]*?)\n---/;
640
- function splitSpecFile(content) {
795
+ function parseSpecDocument(content) {
641
796
  const match = FRONTMATTER_BLOCK.exec(content);
642
- if (match === null || match[1] === void 0) return null;
643
- return { frontmatterText: match[1], body: content.slice(match[0].length) };
644
- }
645
- function presentSectionsOf(body) {
646
- const searchable = `
647
- ${body}`;
648
- const present = [];
649
- let cursor = 0;
650
- for (const section of SECTIONS_SPEC_OBLIGATOIRES) {
651
- const position = searchable.indexOf(`
652
- ${section}`, cursor);
653
- if (position === -1) continue;
654
- present.push(section);
655
- cursor = position + section.length;
656
- }
657
- return present;
658
- }
659
- function missingSectionsOf(body) {
660
- const present = new Set(presentSectionsOf(body));
661
- return SECTIONS_SPEC_OBLIGATOIRES.filter((section) => !present.has(section));
662
- }
663
- function quoteFrontmatterElement(value, specPath) {
664
- if (value.includes("\n") || value.includes('"') && value.includes("'")) {
665
- throwOgygieError(
666
- {
667
- code: "SPEC_FRONTMATTER_UNSERIALIZABLE",
668
- message: "A frontmatter array element cannot be written in the restricted spec format.",
669
- file: specPath,
670
- cause: `element ${JSON.stringify(value)} contains a newline or both quote kinds`,
671
- suggestedFix: "Rewrite the element without newlines and with at most one kind of quote character."
672
- },
673
- 422
674
- );
675
- }
676
- return value.includes('"') ? `'${value}'` : `"${value}"`;
677
- }
678
- function serializeSpecFrontmatter(frontmatter, specPath) {
679
- const quote = (value) => quoteFrontmatterElement(value, specPath);
680
- const lines = [
681
- `id: ${frontmatter.id}`,
682
- `status: ${frontmatter.status}`,
683
- `depends_on: [${frontmatter.depends_on.join(", ")}]`,
684
- `priority: ${frontmatter.priority}`,
685
- `refs: [${frontmatter.refs.map(quote).join(", ")}]`
686
- ];
687
- if (frontmatter.deferred.length > 0) {
688
- lines.push(`deferred: [${frontmatter.deferred.map(quote).join(", ")}]`);
689
- }
690
- return lines.join("\n");
691
- }
692
- function specPathOf(specId) {
693
- return `${SPECS_DIR}/${specId}.spec.md`;
694
- }
695
- function frontmatterInvalidError(filePath, cause) {
696
- return ogygieError({
697
- code: "SPEC_FRONTMATTER_INVALID",
698
- message: `Spec ${filePath} has an invalid frontmatter.`,
699
- file: filePath,
700
- cause,
701
- suggestedFix: "Fix the frontmatter in your editor to match packages/ogygie-core/src/spec.schema.ts \u2014 the studio never repairs a spec silently."
702
- });
703
- }
704
- function parseSpec(filePath, fileName, content) {
705
- const split = splitSpecFile(content);
706
- if (split === null) {
797
+ if (match === null || match[1] === void 0) {
707
798
  return {
708
- ok: false,
709
- error: frontmatterInvalidError(
710
- filePath,
711
- "file does not start with a --- ... --- frontmatter block"
712
- )
799
+ frontmatter: null,
800
+ problems: ["file does not start with a --- ... --- frontmatter block"],
801
+ body: content
713
802
  };
714
803
  }
715
- const parsed = parseSpecFrontmatter(split.frontmatterText);
804
+ const body = content.slice(match[0].length);
805
+ const parsed = parseSpecFrontmatter(match[1]);
716
806
  if (parsed.problems.length > 0) {
717
- return { ok: false, error: frontmatterInvalidError(filePath, parsed.problems.join(" ; ")) };
807
+ return { frontmatter: null, problems: parsed.problems, body };
718
808
  }
719
809
  const checked = SpecFrontmatterSchema.safeParse(parsed.value);
720
810
  if (!checked.success) {
721
811
  return {
722
- ok: false,
723
- error: frontmatterInvalidError(
724
- filePath,
725
- checked.error.issues.map((issue) => `${issue.path.join(".")}: ${issue.message}`).join(" ; ")
726
- )
812
+ frontmatter: null,
813
+ problems: checked.error.issues.map((issue) => `${issue.path.join(".")}: ${issue.message}`),
814
+ body
727
815
  };
728
816
  }
729
- const expectedId = fileName.replace(/\.spec\.md$/, "");
730
- if (checked.data.id !== expectedId) {
731
- return {
732
- ok: false,
733
- error: ogygieError({
734
- code: "SPEC_ID_MISMATCH",
735
- message: `Spec ${fileName} declares id "${checked.data.id}" \u2014 expected "${expectedId}".`,
736
- file: filePath,
737
- cause: `id=${checked.data.id} \u2260 filename ${expectedId}`,
738
- suggestedFix: "Make the frontmatter id identical to the file name."
739
- })
740
- };
817
+ return { frontmatter: checked.data, problems: [], body };
818
+ }
819
+ var DESIGN_SECTION_TITLE = "Design";
820
+ var COMPOSITION_LINE = /^\s*(?:[-*]\s*)?[Cc]omposition\s*:\s*`?([a-z][a-z0-9]*(?:-[a-z0-9]+)*)`?(?![A-Za-z0-9])/mu;
821
+ function compositionOfSpec(content) {
822
+ const heading = new RegExp(`^# ${DESIGN_SECTION_TITLE}\\s*$`, "mu").exec(content);
823
+ if (heading === null) return null;
824
+ const rest = content.slice(heading.index + heading[0].length);
825
+ const next = /^# /mu.exec(rest);
826
+ const section = next === null ? rest : rest.slice(0, next.index);
827
+ return COMPOSITION_LINE.exec(section)?.[1] ?? null;
828
+ }
829
+
830
+ // ../ogygie-core/src/proposal.ts
831
+ var DECISION_FILES = ["DECISIONS.md", "DECISIONS.product.md"];
832
+ var GithubCoordinatesSchema = z5.object({
833
+ owner: z5.string().min(1),
834
+ repo: z5.string().min(1),
835
+ defaultBranch: z5.string().min(1).default("main")
836
+ });
837
+ var ProposablePathSchema = z5.union([
838
+ z5.string().regex(/^specs\/(?!_)[a-z][a-z0-9]*(-[a-z0-9]+)*\.spec\.md$/),
839
+ z5.literal("DECISIONS.md"),
840
+ z5.literal("DECISIONS.product.md")
841
+ ]);
842
+ var ProposalSchema = z5.object({
843
+ repo: z5.string(),
844
+ path: ProposablePathSchema,
845
+ branch: z5.string(),
846
+ baseSha: z5.string(),
847
+ commitSha: z5.string(),
848
+ pullRequestNumber: z5.number().int().positive(),
849
+ pullRequestUrl: z5.url(),
850
+ /** false when an open pull request was updated instead of created. */
851
+ created: z5.boolean()
852
+ });
853
+ function assertProposablePath(path) {
854
+ const parsed = ProposablePathSchema.safeParse(path);
855
+ if (!parsed.success) {
856
+ return throwOgygieError({
857
+ code: "WRITE_PATH_FORBIDDEN",
858
+ message: `"${path}" is outside what a proposal may touch.`,
859
+ file: path,
860
+ cause: "the perimeter is specs/<id>.spec.md (not the template), DECISIONS.md, DECISIONS.product.md",
861
+ suggestedFix: "Propose a spec or a decisions file; code, manifest, governed files and FAILURES.md are out of reach (D17)."
862
+ });
741
863
  }
742
- return { ok: true, spec: { frontmatter: checked.data, body: split.body } };
864
+ return parsed.data;
743
865
  }
744
- function toReadableSummary(filePath, spec) {
866
+ function createGithubWriteClient(deps, github) {
867
+ const apiBase = deps.apiBase ?? "https://api.github.com";
868
+ const repoUrl = `${apiBase}/repos/${github.owner}/${github.repo}`;
869
+ const credential = deps.credentialHint ?? "the GitHub token";
870
+ async function request(method, url, body, allowNotFound) {
871
+ let response;
872
+ try {
873
+ response = await deps.fetchImpl(url, {
874
+ method,
875
+ headers: {
876
+ authorization: `Bearer ${deps.token}`,
877
+ accept: "application/vnd.github+json",
878
+ "x-github-api-version": "2022-11-28",
879
+ "user-agent": deps.userAgent ?? "ogygie-core",
880
+ ...body === void 0 ? {} : { "content-type": "application/json" }
881
+ },
882
+ ...body === void 0 ? {} : { body: JSON.stringify(body) }
883
+ });
884
+ } catch (thrown) {
885
+ return throwOgygieError({
886
+ code: "GITHUB_UNAVAILABLE",
887
+ message: `GitHub could not be reached for ${github.owner}/${github.repo}.`,
888
+ file: null,
889
+ cause: thrown instanceof Error ? thrown.message : String(thrown),
890
+ suggestedFix: "Retry once the network is back \u2014 nothing was written, nothing is queued."
891
+ });
892
+ }
893
+ if (response.status === 404 && allowNotFound) return null;
894
+ if (!response.ok) {
895
+ return throwOgygieError({
896
+ code: "GITHUB_UNAVAILABLE",
897
+ message: `GitHub answered ${response.status} for ${method} ${url.replace(apiBase, "")}.`,
898
+ file: null,
899
+ cause: `status=${response.status}`,
900
+ suggestedFix: response.status === 401 || response.status === 403 ? `Check ${credential}: it must grant contents and pull requests on this repo.` : "Retry later \u2014 nothing was written, nothing is queued."
901
+ });
902
+ }
903
+ return await response.json();
904
+ }
745
905
  return {
746
- id: spec.frontmatter.id,
747
- status: spec.frontmatter.status,
748
- priority: spec.frontmatter.priority,
749
- depends_on: spec.frontmatter.depends_on,
750
- filePath,
751
- presentSections: presentSectionsOf(spec.body)
906
+ async getBranchHead(branch) {
907
+ const ref = await request(
908
+ "GET",
909
+ `${repoUrl}/git/ref/heads/${encodeURIComponent(branch)}`,
910
+ void 0,
911
+ true
912
+ );
913
+ return ref?.object.sha ?? null;
914
+ },
915
+ async readFileAtRef(path, ref) {
916
+ const file = await request(
917
+ "GET",
918
+ `${repoUrl}/contents/${path}?ref=${encodeURIComponent(ref)}`,
919
+ void 0,
920
+ true
921
+ );
922
+ if (file === null) return null;
923
+ return { content: Buffer.from(file.content, "base64").toString("utf8"), sha: file.sha };
924
+ },
925
+ async createBranch(branch, fromSha) {
926
+ await request("POST", `${repoUrl}/git/refs`, { ref: `refs/heads/${branch}`, sha: fromSha }, false);
927
+ },
928
+ async commitFile(input) {
929
+ const result = await request(
930
+ "PUT",
931
+ `${repoUrl}/contents/${input.path}`,
932
+ {
933
+ message: input.message,
934
+ content: Buffer.from(input.content, "utf8").toString("base64"),
935
+ branch: input.branch,
936
+ ...input.expectedSha === null ? {} : { sha: input.expectedSha },
937
+ ...input.author === void 0 ? {} : { author: input.author }
938
+ },
939
+ false
940
+ );
941
+ return { commitSha: result?.commit.sha ?? "" };
942
+ },
943
+ async findOpenPullRequest(branch) {
944
+ const pulls = await request(
945
+ "GET",
946
+ `${repoUrl}/pulls?state=open&head=${encodeURIComponent(`${github.owner}:${branch}`)}`,
947
+ void 0,
948
+ false
949
+ );
950
+ const first = pulls?.[0];
951
+ return first === void 0 ? null : { number: first.number, url: first.html_url };
952
+ },
953
+ async openPullRequest(input) {
954
+ const pull = await request(
955
+ "POST",
956
+ `${repoUrl}/pulls`,
957
+ { title: input.title, body: input.body, head: input.branch, base: input.base },
958
+ false
959
+ );
960
+ return { number: pull?.number ?? 0, url: pull?.html_url ?? "" };
961
+ }
752
962
  };
753
963
  }
754
- async function listSpecFileNames(deps) {
755
- const entries = await deps.storage.listDirectory(SPECS_DIR);
756
- return entries.map((entry) => entry.path).filter((path) => !path.includes("/")).filter((name) => name.endsWith(".spec.md") && !name.startsWith("_")).sort();
964
+ var HOUR_MS = 36e5;
965
+ var DECISION_HEADING2 = /^## D(\d+) — \d{4}-\d{2}-\d{2} — \S/;
966
+ var DECISION_BULLETS = ["**D\xE9cision**", "**Motivation**", "**Exclut**"];
967
+ function specIdOfPath(path) {
968
+ return path.replace(/^specs\//, "").replace(/\.spec\.md$/, "");
757
969
  }
758
- async function listSpecSummaries(deps) {
970
+ function assertSpecProposable(path, content) {
971
+ const parsed = parseSpecDocument(content);
972
+ const problems = [...parsed.problems];
973
+ if (parsed.frontmatter !== null && parsed.frontmatter.id !== specIdOfPath(path)) {
974
+ problems.push(`id "${parsed.frontmatter.id}" differs from the file name "${specIdOfPath(path)}"`);
975
+ }
976
+ let cursor = 0;
977
+ for (const section of SECTIONS_SPEC_OBLIGATOIRES) {
978
+ const position = parsed.body.indexOf(`
979
+ ${section}
980
+ `, cursor);
981
+ if (position === -1) problems.push(`section "${section}" missing or out of order`);
982
+ else cursor = position + section.length;
983
+ }
984
+ if (problems.length > 0) {
985
+ throwOgygieError({
986
+ code: "SPEC_INVALID",
987
+ message: `The proposed spec would fail verify-specs (${problems.length} problem(s)).`,
988
+ file: path,
989
+ cause: problems.join(" ; "),
990
+ suggestedFix: "Fix the frontmatter (id, status, depends_on, priority, refs) and keep the six canonical sections in order."
991
+ });
992
+ }
993
+ if (parsed.frontmatter?.status === "implemented") {
994
+ throwOgygieError({
995
+ code: "SPEC_STATUS_FORBIDDEN",
996
+ message: "A proposal may not declare a spec implemented.",
997
+ file: path,
998
+ cause: "status=implemented",
999
+ suggestedFix: "Propose the spec as draft or validated \u2014 the repo's manifest and CI say when it is implemented."
1000
+ });
1001
+ }
1002
+ }
1003
+ function assertDecisionsProposable(path, content, baseSame, baseOther) {
1004
+ const problems = [];
1005
+ for (const line of content.split("\n")) {
1006
+ if (line.startsWith("## ") && !DECISION_HEADING2.test(line)) {
1007
+ problems.push(`heading "${line}" is not "## D<n> \u2014 AAAA-MM-JJ \u2014 <titre>"`);
1008
+ }
1009
+ }
1010
+ const proposedIds = parseKnownDecisionIds(content);
1011
+ const otherIds = parseKnownDecisionIds(baseOther ?? "");
1012
+ const previousIds = parseKnownDecisionIds(baseSame ?? "");
1013
+ for (const id of previousIds) {
1014
+ if (!proposedIds.includes(id)) problems.push(`${id} was declared and is removed or renumbered`);
1015
+ }
1016
+ const all = [...proposedIds, ...otherIds];
1017
+ const seen = /* @__PURE__ */ new Set();
1018
+ for (const id of all) {
1019
+ if (seen.has(id)) problems.push(`${id} is declared twice across the decision files`);
1020
+ seen.add(id);
1021
+ }
1022
+ const numbers = [...seen].map((id) => Number.parseInt(id.slice(1), 10)).sort((a, b) => a - b);
1023
+ numbers.forEach((value, index) => {
1024
+ if (value !== index + 1) problems.push(`numbering has a gap: expected D${index + 1}, found D${value}`);
1025
+ });
1026
+ for (const id of proposedIds.filter((candidate) => !previousIds.includes(candidate))) {
1027
+ const section = extractDecisionSection(content, id) ?? "";
1028
+ for (const bullet of DECISION_BULLETS) {
1029
+ if (!section.includes(bullet)) problems.push(`${id} lacks its ${bullet} bullet`);
1030
+ }
1031
+ }
1032
+ if (problems.length > 0) {
1033
+ throwOgygieError({
1034
+ code: "DECISION_INVALID",
1035
+ message: `The proposed decisions file breaks the D<n> contract (${problems.length} problem(s)).`,
1036
+ file: path,
1037
+ cause: problems.join(" ; "),
1038
+ suggestedFix: "Keep every existing decision, number the new one right after the last across both files, and give it its D\xE9cision / Motivation / Exclut bullets."
1039
+ });
1040
+ }
1041
+ }
1042
+ function assertDecisionReferencesKnown(path, content, known) {
1043
+ const unknown = findUnknownDecisionReferences(content, known);
1044
+ if (unknown.length > 0) {
1045
+ throwOgygieError({
1046
+ code: "UNKNOWN_DECISION_REFERENCE",
1047
+ message: `The proposal cites ${unknown.length} decision(s) the repo does not declare.`,
1048
+ file: path,
1049
+ cause: unknown.map((reference) => `${reference.decision} (line ${reference.line})`).join(", "),
1050
+ suggestedFix: `Cite only declared decisions (${known.join(", ") || "<none>"}), or propose the decision first.`
1051
+ });
1052
+ }
1053
+ }
1054
+ function branchSlug(path) {
1055
+ return path.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/(^-|-$)/g, "");
1056
+ }
1057
+ function defaultBranchFor(target, path) {
1058
+ return `mcp/${target.id}/${branchSlug(path)}`;
1059
+ }
1060
+ async function withGithub(action) {
1061
+ try {
1062
+ return await action();
1063
+ } catch (thrown) {
1064
+ if (thrown instanceof OgygieFailure) throw thrown;
1065
+ return throwOgygieError({
1066
+ code: "GITHUB_UNAVAILABLE",
1067
+ message: "The GitHub client failed outside its contract.",
1068
+ file: null,
1069
+ cause: thrown instanceof Error ? thrown.message : String(thrown),
1070
+ suggestedFix: "Retry \u2014 nothing was written, nothing is queued."
1071
+ });
1072
+ }
1073
+ }
1074
+ async function proposeChange(target, deps, input) {
1075
+ const path = assertProposablePath(input.path);
1076
+ const github = target.github;
1077
+ const now = deps.now();
1078
+ const recent = (deps.recentProposals.get(target.id) ?? []).filter((at) => now - at < HOUR_MS);
1079
+ if (recent.length >= deps.rateLimitPerHour) {
1080
+ const retryAfterSeconds = Math.ceil(((recent[0] ?? now) + HOUR_MS - now) / 1e3);
1081
+ throwOgygieError({
1082
+ code: "WRITE_RATE_LIMITED",
1083
+ message: `Repo "${target.id}" already received ${recent.length} proposals this hour.`,
1084
+ file: null,
1085
+ cause: `limit=${deps.rateLimitPerHour}/hour`,
1086
+ suggestedFix: `Retry in ${retryAfterSeconds} s, or merge or close the open proposals first.`
1087
+ });
1088
+ }
1089
+ const client = deps.clientFor(github);
1090
+ const baseSha = await withGithub(() => client.getBranchHead(github.defaultBranch));
1091
+ if (baseSha === null) {
1092
+ return throwOgygieError({
1093
+ code: "GITHUB_UNAVAILABLE",
1094
+ message: `Branch "${github.defaultBranch}" does not exist on ${github.owner}/${github.repo}.`,
1095
+ file: null,
1096
+ cause: "default branch head not found",
1097
+ suggestedFix: "Fix `defaultBranch` in the GitHub coordinates of that repo."
1098
+ });
1099
+ }
1100
+ const baseFiles = /* @__PURE__ */ new Map();
1101
+ for (const decisionsPath of DECISION_FILES) {
1102
+ const file = await withGithub(() => client.readFileAtRef(decisionsPath, baseSha));
1103
+ baseFiles.set(decisionsPath, file?.content ?? null);
1104
+ }
1105
+ if (path.startsWith("specs/")) {
1106
+ assertSpecProposable(path, input.content);
1107
+ const known = DECISION_FILES.flatMap((file) => parseKnownDecisionIds(baseFiles.get(file) ?? ""));
1108
+ assertDecisionReferencesKnown(path, input.content, known);
1109
+ } else {
1110
+ const other = DECISION_FILES.find((file) => file !== path) ?? DECISION_FILES[1];
1111
+ assertDecisionsProposable(path, input.content, baseFiles.get(path) ?? null, baseFiles.get(other) ?? null);
1112
+ const known = [...parseKnownDecisionIds(input.content), ...parseKnownDecisionIds(baseFiles.get(other) ?? "")];
1113
+ assertDecisionReferencesKnown(path, input.content, known);
1114
+ }
1115
+ let branch = (deps.branchFor ?? defaultBranchFor)(target, path);
1116
+ let branchHead = await withGithub(() => client.getBranchHead(branch));
1117
+ let openPull = branchHead === null ? null : await withGithub(() => client.findOpenPullRequest(branch));
1118
+ if (branchHead !== null && openPull === null) {
1119
+ branch = `${branch}-${baseSha.slice(0, 7)}`;
1120
+ branchHead = await withGithub(() => client.getBranchHead(branch));
1121
+ openPull = branchHead === null ? null : await withGithub(() => client.findOpenPullRequest(branch));
1122
+ }
1123
+ const headOfBranch = branchHead;
1124
+ const baseFile = await withGithub(() => client.readFileAtRef(path, baseSha));
1125
+ const branchFile = headOfBranch === null ? null : await withGithub(() => client.readFileAtRef(path, headOfBranch));
1126
+ const readsBranch = deps.readFrom === "branch" && headOfBranch !== null;
1127
+ const compared = readsBranch ? branchFile : baseFile;
1128
+ const comparedName = readsBranch ? branch : github.defaultBranch;
1129
+ const local = await target.adapter.exists(path) ? await target.adapter.readFile(path) : null;
1130
+ if ((compared?.content ?? null) !== local) {
1131
+ throwOgygieError({
1132
+ code: "REMOTE_CONFLICT",
1133
+ message: `"${path}" changed on ${comparedName} since it was read.`,
1134
+ file: path,
1135
+ cause: `head sha=${compared?.sha ?? "<absent>"}, the read copy ${local === null ? "has no such file" : "differs"}`,
1136
+ suggestedFix: "Re-read the file (details.headContent is the current head), reapply your change and propose again.",
1137
+ details: { headSha: compared?.sha ?? null, headContent: compared?.content ?? null }
1138
+ });
1139
+ }
1140
+ recent.push(now);
1141
+ deps.recentProposals.set(target.id, recent);
1142
+ const expectedSha = headOfBranch === null ? baseFile?.sha ?? null : branchFile?.sha ?? null;
1143
+ if (headOfBranch === null) {
1144
+ await withGithub(() => client.createBranch(branch, baseSha));
1145
+ }
1146
+ const author = deps.authorOf?.();
1147
+ const commit = await withGithub(
1148
+ () => client.commitFile({
1149
+ branch,
1150
+ path,
1151
+ content: input.content,
1152
+ message: input.message,
1153
+ expectedSha,
1154
+ ...author === void 0 ? {} : { author }
1155
+ })
1156
+ );
1157
+ const pull = openPull ?? await withGithub(
1158
+ () => client.openPullRequest({
1159
+ branch,
1160
+ base: github.defaultBranch,
1161
+ title: (input.message.split("\n")[0] ?? input.message).slice(0, 72),
1162
+ body: `${input.message}
1163
+
1164
+ ---
1165
+ ${deps.originOf(path)}`
1166
+ })
1167
+ );
1168
+ return {
1169
+ repo: target.id,
1170
+ path,
1171
+ branch,
1172
+ baseSha,
1173
+ commitSha: commit.commitSha,
1174
+ pullRequestNumber: pull.number,
1175
+ pullRequestUrl: pull.url,
1176
+ created: openPull === null
1177
+ };
1178
+ }
1179
+
1180
+ // src/server/storage/local-adapter.ts
1181
+ import { mkdir, writeFile } from "node:fs/promises";
1182
+ import { dirname, isAbsolute as isAbsolute2, resolve as resolve2, sep as sep2 } from "node:path";
1183
+ function resolveInsideRoot2(rootDir, relativePath) {
1184
+ const root = resolve2(rootDir);
1185
+ const absolute = resolve2(root, relativePath);
1186
+ if (isAbsolute2(relativePath) || absolute !== root && !absolute.startsWith(root + sep2)) {
1187
+ throwOgygieError(
1188
+ {
1189
+ code: "PATH_OUTSIDE_REPO",
1190
+ message: "The requested path escapes the repo root.",
1191
+ file: relativePath,
1192
+ cause: `relative path "${relativePath}" resolves to "${absolute}", outside "${root}"`,
1193
+ suggestedFix: "Only address files inside the repo (specs/, ogygie.manifest.json)."
1194
+ },
1195
+ 400
1196
+ );
1197
+ }
1198
+ return absolute;
1199
+ }
1200
+ function createLocalSpecStorage(rootDir) {
1201
+ const reader = createFilesystemRepoAdapter(rootDir);
1202
+ return {
1203
+ ...reader,
1204
+ async write(path, content, _message) {
1205
+ const absolute = resolveInsideRoot2(rootDir, path);
1206
+ await mkdir(dirname(absolute), { recursive: true });
1207
+ await writeFile(absolute, content, "utf8");
1208
+ }
1209
+ };
1210
+ }
1211
+
1212
+ // src/server/app.ts
1213
+ import Fastify from "fastify";
1214
+ import {
1215
+ hasZodFastifySchemaValidationErrors,
1216
+ serializerCompiler,
1217
+ validatorCompiler
1218
+ } from "fastify-type-provider-zod";
1219
+
1220
+ // src/server/service.ts
1221
+ var SPECS_DIR = "specs";
1222
+ var TEMPLATE_PATH = "specs/_TEMPLATE.spec.md";
1223
+ var MANIFEST_PATH = "ogygie.manifest.json";
1224
+ var FRONTMATTER_BLOCK2 = /^---\n([\s\S]*?)\n---/;
1225
+ function splitSpecFile(content) {
1226
+ const match = FRONTMATTER_BLOCK2.exec(content);
1227
+ if (match === null || match[1] === void 0) return null;
1228
+ return { frontmatterText: match[1], body: content.slice(match[0].length) };
1229
+ }
1230
+ function presentSectionsOf(body) {
1231
+ const searchable = `
1232
+ ${body}`;
1233
+ const present = [];
1234
+ let cursor = 0;
1235
+ for (const section of SECTIONS_SPEC_OBLIGATOIRES) {
1236
+ const position = searchable.indexOf(`
1237
+ ${section}`, cursor);
1238
+ if (position === -1) continue;
1239
+ present.push(section);
1240
+ cursor = position + section.length;
1241
+ }
1242
+ return present;
1243
+ }
1244
+ function missingSectionsOf(body) {
1245
+ const present = new Set(presentSectionsOf(body));
1246
+ return SECTIONS_SPEC_OBLIGATOIRES.filter((section) => !present.has(section));
1247
+ }
1248
+ function quoteFrontmatterElement(value, specPath) {
1249
+ if (value.includes("\n") || value.includes('"') && value.includes("'")) {
1250
+ throwOgygieError(
1251
+ {
1252
+ code: "SPEC_FRONTMATTER_UNSERIALIZABLE",
1253
+ message: "A frontmatter array element cannot be written in the restricted spec format.",
1254
+ file: specPath,
1255
+ cause: `element ${JSON.stringify(value)} contains a newline or both quote kinds`,
1256
+ suggestedFix: "Rewrite the element without newlines and with at most one kind of quote character."
1257
+ },
1258
+ 422
1259
+ );
1260
+ }
1261
+ return value.includes('"') ? `'${value}'` : `"${value}"`;
1262
+ }
1263
+ function serializeSpecFrontmatter(frontmatter, specPath) {
1264
+ const quote = (value) => quoteFrontmatterElement(value, specPath);
1265
+ const lines = [
1266
+ `id: ${frontmatter.id}`,
1267
+ `status: ${frontmatter.status}`,
1268
+ `depends_on: [${frontmatter.depends_on.join(", ")}]`,
1269
+ `priority: ${frontmatter.priority}`,
1270
+ `refs: [${frontmatter.refs.map(quote).join(", ")}]`
1271
+ ];
1272
+ if (frontmatter.deferred.length > 0) {
1273
+ lines.push(`deferred: [${frontmatter.deferred.map(quote).join(", ")}]`);
1274
+ }
1275
+ return lines.join("\n");
1276
+ }
1277
+ function specPathOf(specId) {
1278
+ return `${SPECS_DIR}/${specId}.spec.md`;
1279
+ }
1280
+ function frontmatterInvalidError(filePath, cause) {
1281
+ return ogygieError({
1282
+ code: "SPEC_FRONTMATTER_INVALID",
1283
+ message: `Spec ${filePath} has an invalid frontmatter.`,
1284
+ file: filePath,
1285
+ cause,
1286
+ suggestedFix: "Fix the frontmatter in your editor to match packages/ogygie-core/src/spec.schema.ts \u2014 the studio never repairs a spec silently."
1287
+ });
1288
+ }
1289
+ function parseSpec(filePath, fileName, content) {
1290
+ const split = splitSpecFile(content);
1291
+ if (split === null) {
1292
+ return {
1293
+ ok: false,
1294
+ error: frontmatterInvalidError(
1295
+ filePath,
1296
+ "file does not start with a --- ... --- frontmatter block"
1297
+ )
1298
+ };
1299
+ }
1300
+ const parsed = parseSpecFrontmatter(split.frontmatterText);
1301
+ if (parsed.problems.length > 0) {
1302
+ return { ok: false, error: frontmatterInvalidError(filePath, parsed.problems.join(" ; ")) };
1303
+ }
1304
+ const checked = SpecFrontmatterSchema.safeParse(parsed.value);
1305
+ if (!checked.success) {
1306
+ return {
1307
+ ok: false,
1308
+ error: frontmatterInvalidError(
1309
+ filePath,
1310
+ checked.error.issues.map((issue) => `${issue.path.join(".")}: ${issue.message}`).join(" ; ")
1311
+ )
1312
+ };
1313
+ }
1314
+ const expectedId = fileName.replace(/\.spec\.md$/, "");
1315
+ if (checked.data.id !== expectedId) {
1316
+ return {
1317
+ ok: false,
1318
+ error: ogygieError({
1319
+ code: "SPEC_ID_MISMATCH",
1320
+ message: `Spec ${fileName} declares id "${checked.data.id}" \u2014 expected "${expectedId}".`,
1321
+ file: filePath,
1322
+ cause: `id=${checked.data.id} \u2260 filename ${expectedId}`,
1323
+ suggestedFix: "Make the frontmatter id identical to the file name."
1324
+ })
1325
+ };
1326
+ }
1327
+ return { ok: true, spec: { frontmatter: checked.data, body: split.body } };
1328
+ }
1329
+ function toReadableSummary(filePath, spec) {
1330
+ return {
1331
+ id: spec.frontmatter.id,
1332
+ status: spec.frontmatter.status,
1333
+ priority: spec.frontmatter.priority,
1334
+ depends_on: spec.frontmatter.depends_on,
1335
+ filePath,
1336
+ presentSections: presentSectionsOf(spec.body)
1337
+ };
1338
+ }
1339
+ async function listSpecFileNames(deps) {
1340
+ const entries = await deps.storage.listDirectory(SPECS_DIR);
1341
+ return entries.map((entry) => entry.path).filter((path) => !path.includes("/")).filter((name) => name.endsWith(".spec.md") && !name.startsWith("_")).sort();
1342
+ }
1343
+ async function listSpecSummaries(deps) {
759
1344
  const fileNames = await listSpecFileNames(deps);
760
1345
  const summaries = [];
761
1346
  for (const fileName of fileNames) {
@@ -947,64 +1532,180 @@ async function getManifest(deps) {
947
1532
  }
948
1533
 
949
1534
  // src/server/schemas.ts
950
- import { z as z5 } from "zod";
1535
+ import { z as z6 } from "zod";
951
1536
  var SpecStatusSchema = SpecFrontmatterSchema.shape.status;
952
- var ReadableSpecSummarySchema = z5.object({
1537
+ var ReadableSpecSummarySchema = z6.object({
953
1538
  id: FeatureNameSchema,
954
1539
  status: SpecStatusSchema,
955
1540
  priority: SpecFrontmatterSchema.shape.priority,
956
- depends_on: z5.array(FeatureNameSchema),
957
- filePath: z5.string(),
958
- presentSections: z5.array(z5.string())
1541
+ depends_on: z6.array(FeatureNameSchema),
1542
+ filePath: z6.string(),
1543
+ presentSections: z6.array(z6.string())
959
1544
  });
960
- var UnreadableSpecSummarySchema = z5.object({
961
- id: z5.string(),
962
- filePath: z5.string(),
1545
+ var UnreadableSpecSummarySchema = z6.object({
1546
+ id: z6.string(),
1547
+ filePath: z6.string(),
963
1548
  error: OgygieErrorSchema
964
1549
  });
965
- var SpecSummarySchema = z5.union([
1550
+ var SpecSummarySchema = z6.union([
966
1551
  ReadableSpecSummarySchema,
967
1552
  UnreadableSpecSummarySchema
968
1553
  ]);
969
- var ListSpecsOutputSchema = z5.object({
970
- specs: z5.array(SpecSummarySchema)
1554
+ var ListSpecsOutputSchema = z6.object({
1555
+ specs: z6.array(SpecSummarySchema)
971
1556
  });
972
- var SpecParamsSchema = z5.object({
1557
+ var SpecParamsSchema = z6.object({
973
1558
  specId: FeatureNameSchema
974
1559
  });
975
- var GetSpecOutputSchema = z5.object({
1560
+ var GetSpecOutputSchema = z6.object({
976
1561
  frontmatter: SpecFrontmatterSchema,
977
1562
  /** Markdown body after the frontmatter block, byte-exact. */
978
- body: z5.string(),
979
- presentSections: z5.array(z5.string())
1563
+ body: z6.string(),
1564
+ presentSections: z6.array(z6.string())
980
1565
  });
981
- var UpdateSpecInputSchema = z5.object({
1566
+ var UpdateSpecInputSchema = z6.object({
982
1567
  frontmatter: SpecFrontmatterSchema,
983
1568
  /** Omitted ⇒ the existing markdown body is preserved byte-exact. */
984
- body: z5.string().max(2e5).optional()
1569
+ body: z6.string().max(2e5).optional()
985
1570
  });
986
- var UpdateSpecOutputSchema = z5.object({
1571
+ var UpdateSpecOutputSchema = z6.object({
987
1572
  spec: ReadableSpecSummarySchema
988
1573
  });
989
- var CreateSpecInputSchema = z5.object({
1574
+ var CreateSpecInputSchema = z6.object({
990
1575
  id: FeatureNameSchema.max(80)
991
1576
  });
992
- var CreateSpecOutputSchema = z5.object({
1577
+ var CreateSpecOutputSchema = z6.object({
993
1578
  spec: ReadableSpecSummarySchema
994
1579
  });
995
- var GetManifestOutputSchema = z5.object({
1580
+ var GetManifestOutputSchema = z6.object({
996
1581
  manifest: OgygieManifestSchema
997
1582
  });
1583
+ var BriefInputSchema = z6.object({
1584
+ brief: z6.string().min(50).max(2e4),
1585
+ /** Absent: the model names the new spec. Present and existing: an amendment. */
1586
+ specId: FeatureNameSchema.optional()
1587
+ });
1588
+ var BriefOutputSchema = z6.object({
1589
+ specId: FeatureNameSchema,
1590
+ attempts: z6.number().int().min(1).max(2),
1591
+ problemsFixed: z6.array(z6.string()),
1592
+ /** No feature of the manifest carries this id: its first schemas.ts awaits the operator (D52, lot C). */
1593
+ isNewFeature: z6.boolean(),
1594
+ isAmendment: z6.boolean(),
1595
+ /** D53: the compositions the model proposed for a screen, the retained one (the head's) first; empty when the rubric is not a screen. */
1596
+ compositions: z6.array(z6.string()).default([]),
1597
+ /** The generator's tally on the draft's `# Génération` block (lines bound without a model, lines carried by a hole); null without a block. */
1598
+ generation: z6.object({ derivable: z6.number().int().nonnegative(), hole: z6.number().int().nonnegative() }).nullable().default(null),
1599
+ usage: z6.array(
1600
+ z6.object({ model: z6.string(), inputTokens: z6.number().int().nonnegative(), outputTokens: z6.number().int().nonnegative() })
1601
+ )
1602
+ });
1603
+ var ChecksStateSchema = z6.enum(["green", "red", "pending", "none"]);
1604
+ var PullRequestStatusSchema = z6.object({
1605
+ specId: FeatureNameSchema,
1606
+ branch: z6.string(),
1607
+ pullRequest: z6.object({ number: z6.number().int().positive(), url: z6.string(), headSha: z6.string(), title: z6.string() }).nullable(),
1608
+ specStatus: z6.enum(["draft", "validated", "implemented", "unreadable", "absent"]),
1609
+ checks: ChecksStateSchema,
1610
+ checkRuns: z6.array(z6.object({ name: z6.string(), conclusion: z6.string().nullable() })),
1611
+ mergeable: z6.boolean().nullable(),
1612
+ requiredRole: z6.enum(["approver", "operator"]),
1613
+ canMerge: z6.boolean(),
1614
+ /** Each thing standing between the PR and its merge, in the user's language. */
1615
+ blockers: z6.array(z6.string()),
1616
+ labels: z6.array(z6.string()).default([]),
1617
+ /** D52: the first schemas.ts of a new feature failed the gates twice — the operator has the hand (label needs-operator). */
1618
+ needsOperator: z6.boolean().default(false)
1619
+ });
1620
+ var SpecPullRequestsSchema = z6.object({
1621
+ pullRequests: z6.array(z6.object({ specId: FeatureNameSchema, number: z6.number().int().positive(), url: z6.string(), checks: ChecksStateSchema, needsOperator: z6.boolean().default(false) }))
1622
+ });
1623
+ var MergeOutputSchema = z6.object({
1624
+ specId: FeatureNameSchema,
1625
+ pullRequestNumber: z6.number().int().positive(),
1626
+ mergeSha: z6.string(),
1627
+ mergedBy: z6.string(),
1628
+ mergedAt: z6.string()
1629
+ });
1630
+ var EnvironmentNameSchema = z6.enum(["development", "staging", "production"]);
1631
+ var PromotableEnvironmentSchema = z6.enum(["staging", "production"]);
1632
+ var EnvironmentParamsSchema = z6.object({ environment: PromotableEnvironmentSchema });
1633
+ var EnvironmentsStatusSchema = z6.object({
1634
+ environments: z6.array(
1635
+ z6.object({
1636
+ name: EnvironmentNameSchema,
1637
+ ref: z6.string(),
1638
+ sha: z6.string().nullable(),
1639
+ deployment: z6.object({ id: z6.number().int(), state: z6.string().nullable(), createdAt: z6.string() }).nullable()
1640
+ })
1641
+ ),
1642
+ /** release/<date>.<n>, most recent first. */
1643
+ releases: z6.array(z6.string())
1644
+ });
1645
+ var PromotionInputSchema = z6.object({
1646
+ sha: z6.string().regex(/^[0-9a-f]{40}$/),
1647
+ /** Operator only: a step back, forced, with its reason. */
1648
+ rollback: z6.boolean().optional(),
1649
+ reason: z6.string().min(10).max(2e3).optional()
1650
+ });
1651
+ var PromotionRecordSchema = z6.object({
1652
+ environment: PromotableEnvironmentSchema,
1653
+ fromSha: z6.string().nullable(),
1654
+ toSha: z6.string(),
1655
+ actor: z6.string(),
1656
+ at: z6.string(),
1657
+ deploymentId: z6.number().int(),
1658
+ releaseTag: z6.string().nullable(),
1659
+ includedPullRequests: z6.array(z6.object({ number: z6.number().int(), specId: z6.string().nullable(), message: z6.string(), authoredAt: z6.string() })),
1660
+ destructiveMigration: z6.boolean(),
1661
+ rollback: z6.boolean()
1662
+ });
1663
+ var ConsumptionQuerySchema = z6.object({ month: z6.string().regex(/^\d{4}-\d{2}$/).optional() });
1664
+ var ConsumptionReportSchema = z6.object({
1665
+ month: z6.string(),
1666
+ specs: z6.array(
1667
+ z6.object({
1668
+ specId: z6.string(),
1669
+ pullRequestNumber: z6.number().int(),
1670
+ runs: z6.number().int(),
1671
+ inputTokens: z6.number().int(),
1672
+ outputTokens: z6.number().int(),
1673
+ estimatedUsd: z6.number(),
1674
+ uncostedModels: z6.array(z6.string())
1675
+ })
1676
+ ),
1677
+ totals: z6.object({ runs: z6.number().int(), inputTokens: z6.number().int(), outputTokens: z6.number().int(), estimatedUsd: z6.number() }),
1678
+ budgetUsd: z6.number().nullable(),
1679
+ budgetRatio: z6.number().nullable(),
1680
+ warning: z6.boolean()
1681
+ });
1682
+ var PreviewEntrySchema = z6.object({ state: z6.string(), path: z6.string(), sizeBytes: z6.number().int().nonnegative() });
1683
+ var PreviewsListSchema = z6.object({
1684
+ specId: FeatureNameSchema,
1685
+ /** In the order of the states: empty, loading, nominal, error, then the rest — the pages of the composition the head declares, or of the root. */
1686
+ previews: z6.array(PreviewEntrySchema),
1687
+ /** D53: the composition the spec at the head declares under `# Design`; null when the screen has one composition. */
1688
+ composition: z6.string().nullable().default(null),
1689
+ /** D53: every composition the branch rendered, `selected` for the head's. */
1690
+ compositions: z6.array(z6.object({ name: z6.string(), selected: z6.boolean(), previews: z6.array(PreviewEntrySchema) })).default([])
1691
+ });
1692
+ var SelectCompositionOutputSchema = z6.object({
1693
+ specId: FeatureNameSchema,
1694
+ composition: z6.string(),
1695
+ /** The head already declared it: nothing was written. */
1696
+ alreadySelected: z6.boolean()
1697
+ });
998
1698
 
999
1699
  // src/server/routes.ts
1000
- async function registerStudioRoutes(app, deps) {
1700
+ async function registerStudioRoutes(app, resolver) {
1001
1701
  const route = app.withTypeProvider();
1702
+ const depsOf = (request) => typeof resolver === "function" ? resolver(request) : resolver;
1002
1703
  route.route({
1003
1704
  method: "GET",
1004
1705
  url: "/api/specs",
1005
1706
  schema: { response: { 200: ListSpecsOutputSchema } },
1006
- handler: async (_request, reply) => {
1007
- return reply.code(200).send({ specs: await listSpecSummaries(deps) });
1707
+ handler: async (request, reply) => {
1708
+ return reply.code(200).send({ specs: await listSpecSummaries(depsOf(request)) });
1008
1709
  }
1009
1710
  });
1010
1711
  route.route({
@@ -1012,7 +1713,7 @@ async function registerStudioRoutes(app, deps) {
1012
1713
  url: "/api/specs/:specId",
1013
1714
  schema: { params: SpecParamsSchema, response: { 200: GetSpecOutputSchema } },
1014
1715
  handler: async (request, reply) => {
1015
- return reply.code(200).send(await getSpec(deps, request.params.specId));
1716
+ return reply.code(200).send(await getSpec(depsOf(request), request.params.specId));
1016
1717
  }
1017
1718
  });
1018
1719
  route.route({
@@ -1024,7 +1725,7 @@ async function registerStudioRoutes(app, deps) {
1024
1725
  response: { 200: UpdateSpecOutputSchema }
1025
1726
  },
1026
1727
  handler: async (request, reply) => {
1027
- const spec = await saveSpec(deps, request.params.specId, request.body);
1728
+ const spec = await saveSpec(depsOf(request), request.params.specId, request.body);
1028
1729
  return reply.code(200).send({ spec });
1029
1730
  }
1030
1731
  });
@@ -1036,7 +1737,7 @@ async function registerStudioRoutes(app, deps) {
1036
1737
  response: { 201: CreateSpecOutputSchema }
1037
1738
  },
1038
1739
  handler: async (request, reply) => {
1039
- const spec = await createSpec(deps, request.body.id);
1740
+ const spec = await createSpec(depsOf(request), request.body.id);
1040
1741
  return reply.code(201).send({ spec });
1041
1742
  }
1042
1743
  });
@@ -1044,28 +1745,1410 @@ async function registerStudioRoutes(app, deps) {
1044
1745
  method: "GET",
1045
1746
  url: "/api/manifest",
1046
1747
  schema: { response: { 200: GetManifestOutputSchema } },
1047
- handler: async (_request, reply) => {
1048
- return reply.code(200).send({ manifest: await getManifest(deps) });
1748
+ handler: async (request, reply) => {
1749
+ return reply.code(200).send({ manifest: await getManifest(depsOf(request)) });
1049
1750
  }
1050
1751
  });
1051
1752
  }
1052
1753
 
1053
- // src/server/app.ts
1054
- async function createStudioApp(options) {
1055
- const app = Fastify({ logger: false });
1056
- app.setValidatorCompiler(validatorCompiler);
1057
- app.setSerializerCompiler(serializerCompiler);
1058
- app.setErrorHandler((error, _request, reply) => {
1059
- if (error instanceof OgygieFailure) {
1060
- return reply.status(error.httpStatus).send(error.error);
1061
- }
1062
- if (hasZodFastifySchemaValidationErrors(error)) {
1063
- return reply.status(400).send(
1064
- ogygieError({
1065
- code: "CONTRACT_VALIDATION_FAILED",
1066
- message: "The request does not satisfy the route's input contract.",
1067
- file: null,
1068
- cause: error.validation.map((issue) => `${issue.instancePath || "(root)"}: ${issue.message ?? "invalid"}`).join(" ; "),
1754
+ // src/server/hosted/routes.ts
1755
+ import { z as z8 } from "zod";
1756
+
1757
+ // src/server/hosted/auth.ts
1758
+ import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
1759
+ var MAGIC_LINK_TTL_MS = 15 * 60 * 1e3;
1760
+ var SESSION_COOKIE = "ogygie_host_session";
1761
+ var base64url = (input) => Buffer.from(input).toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
1762
+ var fromBase64url = (input) => Buffer.from(input.replace(/-/g, "+").replace(/_/g, "/"), "base64");
1763
+ function sign(payload, secret) {
1764
+ return base64url(createHmac("sha256", secret).update(payload).digest());
1765
+ }
1766
+ function seal(value, secret) {
1767
+ const payload = base64url(JSON.stringify(value));
1768
+ return `${payload}.${sign(payload, secret)}`;
1769
+ }
1770
+ function open(token, secret) {
1771
+ const [payload, signature, ...rest] = token.split(".");
1772
+ if (payload === void 0 || signature === void 0 || rest.length > 0) return null;
1773
+ const expected = Buffer.from(sign(payload, secret));
1774
+ const given = Buffer.from(signature);
1775
+ if (expected.length !== given.length || !timingSafeEqual(expected, given)) return null;
1776
+ try {
1777
+ const parsed = JSON.parse(fromBase64url(payload).toString("utf8"));
1778
+ return typeof parsed === "object" && parsed !== null ? parsed : null;
1779
+ } catch {
1780
+ return null;
1781
+ }
1782
+ }
1783
+ function issueMagicLink(deps, email) {
1784
+ const exp = deps.now() + (deps.ttlMs ?? MAGIC_LINK_TTL_MS);
1785
+ return seal({ kind: "magic-link", email: email.toLowerCase(), exp, nonce: randomBytes(16).toString("hex") }, deps.secret);
1786
+ }
1787
+ function consumeMagicLink(deps, token) {
1788
+ const now = deps.now();
1789
+ for (const [nonce2, exp2] of deps.consumed) if (exp2 <= now) deps.consumed.delete(nonce2);
1790
+ const value = open(token, deps.secret);
1791
+ const email = value?.["email"];
1792
+ const exp = value?.["exp"];
1793
+ const nonce = value?.["nonce"];
1794
+ const valid = value?.["kind"] === "magic-link" && typeof email === "string" && typeof exp === "number" && typeof nonce === "string" && exp > now && !deps.consumed.has(nonce);
1795
+ if (!valid) {
1796
+ return throwOgygieError(
1797
+ {
1798
+ code: "MAGIC_LINK_EXPIRED",
1799
+ message: "This sign-in link is invalid, expired or already used.",
1800
+ file: null,
1801
+ cause: value === null ? "signature or format invalid" : "expired or consumed",
1802
+ suggestedFix: "Ask for a new link from the sign-in screen; each link works once, for fifteen minutes."
1803
+ },
1804
+ 401
1805
+ );
1806
+ }
1807
+ deps.consumed.set(nonce, exp);
1808
+ return email;
1809
+ }
1810
+ function issueSession(deps, email) {
1811
+ return seal({ kind: "session", email: email.toLowerCase(), exp: deps.now() + deps.ttlHours * 36e5 }, deps.secret);
1812
+ }
1813
+ function readSession(deps, cookieHeader) {
1814
+ const token = cookieValue(cookieHeader, SESSION_COOKIE);
1815
+ if (token === null) return null;
1816
+ const value = open(token, deps.secret);
1817
+ const email = value?.["email"];
1818
+ const exp = value?.["exp"];
1819
+ if (value?.["kind"] !== "session" || typeof email !== "string" || typeof exp !== "number" || exp <= deps.now()) {
1820
+ return null;
1821
+ }
1822
+ return email;
1823
+ }
1824
+ function cookieValue(cookieHeader, name) {
1825
+ if (cookieHeader === void 0) return null;
1826
+ for (const part of cookieHeader.split(";")) {
1827
+ const [key, ...rest] = part.trim().split("=");
1828
+ if (key === name) return decodeURIComponent(rest.join("="));
1829
+ }
1830
+ return null;
1831
+ }
1832
+ function sessionCookie(value, ttlHours) {
1833
+ return `${SESSION_COOKIE}=${encodeURIComponent(value)}; Path=/; Max-Age=${ttlHours * 3600}; HttpOnly; Secure; SameSite=Lax`;
1834
+ }
1835
+ function clearedSessionCookie() {
1836
+ return `${SESSION_COOKIE}=; Path=/; Max-Age=0; HttpOnly; Secure; SameSite=Lax`;
1837
+ }
1838
+ var ROLE_RANK = { reader: 0, author: 1, approver: 2, operator: 3 };
1839
+ function hasRole(user, required) {
1840
+ return ROLE_RANK[user.role] >= ROLE_RANK[required];
1841
+ }
1842
+ function requireRole(user, required, action) {
1843
+ if (hasRole(user, required)) return;
1844
+ throwOgygieError(
1845
+ {
1846
+ code: "HOST_ROLE_FORBIDDEN",
1847
+ message: `"${action}" requires the ${required} role.`,
1848
+ file: null,
1849
+ cause: `${user.email} has role ${user.role}`,
1850
+ suggestedFix: `Ask the operator to grant the ${required} role in the instance configuration.`
1851
+ },
1852
+ 403
1853
+ );
1854
+ }
1855
+ function unauthenticated(cause) {
1856
+ return throwOgygieError(
1857
+ {
1858
+ code: "HOST_UNAUTHENTICATED",
1859
+ message: "This hosted studio requires a signed-in user.",
1860
+ file: null,
1861
+ cause,
1862
+ suggestedFix: "Sign in at /login \u2014 a magic link is sent to a declared email address."
1863
+ },
1864
+ 401
1865
+ );
1866
+ }
1867
+
1868
+ // src/server/hosted/config.ts
1869
+ import { z as z7 } from "zod";
1870
+ var HostRoleSchema = z7.enum(["reader", "author", "approver", "operator"]);
1871
+ var MergePolicySchema = z7.enum(["client", "operator"]);
1872
+ var HostUserSchema = z7.object({
1873
+ email: z7.email(),
1874
+ displayName: z7.string().min(1).max(120),
1875
+ role: HostRoleSchema
1876
+ });
1877
+ var HostConfigSchema = z7.object({
1878
+ repo: GithubCoordinatesSchema,
1879
+ githubApp: z7.object({
1880
+ appId: z7.string().min(1),
1881
+ installationId: z7.string().min(1),
1882
+ privateKeyPath: z7.string().min(1)
1883
+ }),
1884
+ users: z7.array(HostUserSchema).min(1),
1885
+ /** "operator" at onboarding: the agency keeps the merge until it hands it over (D48). */
1886
+ mergePolicy: MergePolicySchema.default("operator"),
1887
+ writeRateLimit: z7.number().int().positive().default(20),
1888
+ session: z7.object({
1889
+ secretPath: z7.string().min(1),
1890
+ ttlHours: z7.number().int().positive().max(168).default(24)
1891
+ }),
1892
+ mail: z7.object({
1893
+ fromAddress: z7.email(),
1894
+ /** HTTP endpoint of the mail provider: POST JSON { from, to, subject, text }. */
1895
+ providerUrl: z7.url(),
1896
+ apiKeyPath: z7.string().min(1)
1897
+ }),
1898
+ /** Base of the magic links — what the client's browser can reach. */
1899
+ publicUrl: z7.url(),
1900
+ /** Lot D (D49): the two promotable refs. Optional until that lot lands. */
1901
+ environments: z7.object({
1902
+ staging: z7.object({ ref: z7.literal("env/staging") }),
1903
+ production: z7.object({ ref: z7.literal("env/production") })
1904
+ }).optional(),
1905
+ /**
1906
+ * The client's model workspace (D50). Present, the brief → spec route exists
1907
+ * and the launcher requires OGYGIE_MODEL_API_KEY; absent, the instance never
1908
+ * calls a model. `specModel` is the `spec` model of D24.
1909
+ */
1910
+ model: z7.object({
1911
+ specModel: z7.string().min(1).default("claude-fable-5-1"),
1912
+ workspaceId: z7.string().min(1).optional(),
1913
+ monthlyBudgetUsd: z7.number().positive().optional(),
1914
+ /** USD per million tokens, by model id — overrides the built-in table of the estimate (B8). */
1915
+ costPerMillionUsd: z7.record(z7.string(), z7.object({ input: z7.number().nonnegative(), output: z7.number().nonnegative() })).optional()
1916
+ }).optional()
1917
+ });
1918
+ function parseHostConfig(raw, configPath) {
1919
+ const parsed = HostConfigSchema.safeParse(raw);
1920
+ if (!parsed.success) {
1921
+ return throwOgygieError({
1922
+ code: "HOST_CONFIG_INVALID",
1923
+ message: "The hosted studio configuration does not match HostConfigSchema.",
1924
+ file: configPath,
1925
+ cause: parsed.error.issues.map((issue) => `${issue.path.join(".") || "(root)"}: ${issue.message}`).join(" ; "),
1926
+ suggestedFix: "Fix the listed fields \u2014 see HostConfigSchema in @ogygie/studio (repo, githubApp, users, session, mail, publicUrl)."
1927
+ });
1928
+ }
1929
+ return parsed.data;
1930
+ }
1931
+ function findHostUser(config, email) {
1932
+ const wanted = email.trim().toLowerCase();
1933
+ return config.users.find((user) => user.email.toLowerCase() === wanted) ?? null;
1934
+ }
1935
+
1936
+ // src/server/hosted/routes.ts
1937
+ var usersByRequest = /* @__PURE__ */ new WeakMap();
1938
+ function hostedUserOf(request) {
1939
+ const user = usersByRequest.get(request);
1940
+ if (user === void 0) return unauthenticated("no session attached to this request");
1941
+ return user;
1942
+ }
1943
+ var MagicLinkInputSchema = z8.object({ email: z8.email() });
1944
+ var CallbackQuerySchema = z8.object({ token: z8.string().min(1) });
1945
+ var MeOutputSchema = z8.object({
1946
+ email: z8.email(),
1947
+ displayName: z8.string(),
1948
+ role: HostRoleSchema
1949
+ });
1950
+ async function registerHostedRoutes(app, deps) {
1951
+ const sessions = {
1952
+ secret: deps.sessionSecret,
1953
+ now: deps.now,
1954
+ ttlHours: deps.config.session.ttlHours
1955
+ };
1956
+ const links = {
1957
+ secret: deps.sessionSecret,
1958
+ now: deps.now,
1959
+ consumed: deps.consumedLinks,
1960
+ ...deps.magicLinkTtlMs === void 0 ? {} : { ttlMs: deps.magicLinkTtlMs }
1961
+ };
1962
+ const PUBLIC_ROUTES = /* @__PURE__ */ new Set(["/api/auth/magic-link", "/api/auth/callback", "/api/auth/logout"]);
1963
+ app.addHook("onRequest", async (request) => {
1964
+ const url = request.url.split("?")[0] ?? request.url;
1965
+ if (!url.startsWith("/api/") || PUBLIC_ROUTES.has(url)) return;
1966
+ const email = readSession(sessions, request.headers.cookie);
1967
+ if (email === null) return unauthenticated("missing or expired session cookie");
1968
+ const user = findHostUser(deps.config, email);
1969
+ if (user === null) return unauthenticated(`session for ${email}, no longer a declared user`);
1970
+ usersByRequest.set(request, user);
1971
+ });
1972
+ const route = app.withTypeProvider();
1973
+ route.route({
1974
+ method: "POST",
1975
+ url: "/api/auth/magic-link",
1976
+ schema: { body: MagicLinkInputSchema, response: { 202: z8.object({ accepted: z8.literal(true) }) } },
1977
+ handler: async (request, reply) => {
1978
+ const user = findHostUser(deps.config, request.body.email);
1979
+ if (user === null) {
1980
+ deps.log(JSON.stringify({ action: "magic-link", outcome: "HOST_USER_UNKNOWN" }));
1981
+ } else {
1982
+ const token = issueMagicLink(links, user.email);
1983
+ const link = `${deps.config.publicUrl.replace(/\/$/, "")}/api/auth/callback?token=${encodeURIComponent(token)}`;
1984
+ await deps.mail.send({
1985
+ to: user.email,
1986
+ subject: "Votre lien de connexion au studio",
1987
+ text: `Bonjour ${user.displayName},
1988
+
1989
+ Ouvrez ce lien pour vous connecter (valable quinze minutes, une seule fois) :
1990
+ ${link}
1991
+ `
1992
+ });
1993
+ deps.log(JSON.stringify({ action: "magic-link", actor: user.email, outcome: "sent" }));
1994
+ }
1995
+ return reply.code(202).send({ accepted: true });
1996
+ }
1997
+ });
1998
+ route.route({
1999
+ method: "GET",
2000
+ url: "/api/auth/callback",
2001
+ schema: { querystring: CallbackQuerySchema },
2002
+ handler: async (request, reply) => {
2003
+ const email = consumeMagicLink(links, request.query.token);
2004
+ const user = findHostUser(deps.config, email);
2005
+ if (user === null) return unauthenticated(`link for ${email}, no longer a declared user`);
2006
+ deps.log(JSON.stringify({ action: "sign-in", actor: user.email, outcome: "ok" }));
2007
+ return reply.header("set-cookie", sessionCookie(issueSession(sessions, user.email), deps.config.session.ttlHours)).redirect("/", 302);
2008
+ }
2009
+ });
2010
+ route.route({
2011
+ method: "GET",
2012
+ url: "/api/auth/me",
2013
+ schema: { response: { 200: MeOutputSchema } },
2014
+ handler: async (request, reply) => {
2015
+ return reply.code(200).send(hostedUserOf(request));
2016
+ }
2017
+ });
2018
+ route.route({
2019
+ method: "POST",
2020
+ url: "/api/auth/logout",
2021
+ handler: async (_request, reply) => {
2022
+ return reply.header("set-cookie", clearedSessionCookie()).code(204).send();
2023
+ }
2024
+ });
2025
+ }
2026
+
2027
+ // src/server/hosted/brief.ts
2028
+ import { z as z9 } from "zod";
2029
+ import { checkSpec, generationGrammarCard, readManifestGeneration } from "@ogygie/generate";
2030
+ var TEMPLATE_PATH2 = "specs/_TEMPLATE.spec.md";
2031
+ var PRODUCT_RULES_PATH = "AGENTS.product.md";
2032
+ var MANIFEST_PATH2 = "ogygie.manifest.json";
2033
+ var DESIGN_TOKENS_PATH = "design/tokens.json";
2034
+ var PATTERNS_CATALOG_PATH = "design/patterns.md";
2035
+ var DESIGN_BRIEFS_DIR = "design/briefs";
2036
+ var MAX_ATTEMPTS = 2;
2037
+ var MAX_ATTEMPTS_GENERATED = 3;
2038
+ var MAX_COMPOSITIONS = 3;
2039
+ var COMPOSITION_SEPARATOR = "=== COMPOSITION ===";
2040
+ var DERIVED_BRIEF_MAX = 6e3;
2041
+ var BRIEF_IN_MESSAGE_MAX = 2e3;
2042
+ async function readIfPresent(storage, path) {
2043
+ return await storage.exists(path) ? storage.readFile(path) : null;
2044
+ }
2045
+ var ManifestRouteSchema = z9.looseObject({ method: z9.string(), path: z9.string(), schemaInput: z9.string().optional(), schemaOutput: z9.string().optional(), invalidates: z9.array(z9.string()).optional() });
2046
+ var ManifestForPromptSchema = z9.looseObject({
2047
+ product: z9.looseObject({ name: z9.string(), description: z9.string().optional() }).optional(),
2048
+ features: z9.array(z9.looseObject({ name: z9.string(), intent: z9.string().optional(), status: z9.string().optional(), spec: z9.string().nullable().optional(), routes: z9.array(ManifestRouteSchema).optional() })).optional()
2049
+ });
2050
+ async function manifestSummary(storage) {
2051
+ const raw = await readIfPresent(storage, MANIFEST_PATH2);
2052
+ const featureIds = /* @__PURE__ */ new Set();
2053
+ if (raw === null) return { text: "(manifeste absent)", featureIds, generation: null, features: [] };
2054
+ let parsed;
2055
+ let rawJson;
2056
+ try {
2057
+ rawJson = JSON.parse(raw);
2058
+ parsed = ManifestForPromptSchema.safeParse(rawJson);
2059
+ } catch {
2060
+ return { text: "(manifeste illisible)", featureIds, generation: null, features: [] };
2061
+ }
2062
+ if (!parsed.success) return { text: "(manifeste illisible)", featureIds, generation: null, features: [] };
2063
+ const generationRead = readManifestGeneration(rawJson);
2064
+ const generation = generationRead.ok ? generationRead.generation : null;
2065
+ const product = parsed.data.product;
2066
+ const lines = [
2067
+ product === void 0 ? "Produit : (non nomm\xE9)" : `Produit : ${product.name}${product.description === void 0 ? "" : ` \u2014 ${product.description}`}`,
2068
+ "Features d\xE9clar\xE9es au manifeste :"
2069
+ ];
2070
+ for (const feature of parsed.data.features ?? []) {
2071
+ featureIds.add(feature.name);
2072
+ lines.push(`- ${feature.name} (${feature.status ?? "?"}) : ${feature.intent ?? ""}`);
2073
+ }
2074
+ return { text: lines.join("\n"), featureIds, generation, features: parsed.data.features ?? [] };
2075
+ }
2076
+ async function consumedFeaturesOf(storage, features, generation, consumedIds) {
2077
+ const consumed = /* @__PURE__ */ new Map();
2078
+ for (const featureId of consumedIds) {
2079
+ const entry = features.find((feature) => feature.name === featureId);
2080
+ if (entry === void 0) continue;
2081
+ const specPath = entry.spec ?? null;
2082
+ const markdown = specPath === null ? null : await readIfPresent(storage, specPath);
2083
+ const report = markdown === null || specPath === null ? null : checkSpec({ markdown, specPath, generation });
2084
+ consumed.set(featureId, {
2085
+ featureId,
2086
+ block: report?.kind === "feature" ? report.block : null,
2087
+ schemas: null,
2088
+ routes: (entry.routes ?? []).map((route) => ({ method: route.method, path: route.path, schemaInput: route.schemaInput ?? "", schemaOutput: route.schemaOutput ?? "", ...route.invalidates === void 0 ? {} : { invalidates: [...route.invalidates] } }))
2089
+ });
2090
+ }
2091
+ return consumed;
2092
+ }
2093
+ async function judgeGeneration(input) {
2094
+ const first = checkSpec({ markdown: input.content, specPath: input.specPath, generation: input.generation });
2095
+ if (!first.generated) return { problems: [], tally: null };
2096
+ if (input.generation === null) return { problems: ["# G\xE9n\xE9ration: the product's manifest declares no generation block \u2014 remove the section, this product is not generated"], tally: null };
2097
+ const report = first.kind === "screen" && first.screen !== null ? checkSpec({ markdown: input.content, specPath: input.specPath, generation: input.generation, consumed: await consumedFeaturesOf(input.storage, input.features, input.generation, first.screen.consumes.map((consumed) => consumed.feature)) }) : first;
2098
+ const problems = report.problems.map((problem) => `${problem.code}${problem.details?.["path"] === void 0 ? "" : ` ${String(problem.details["path"])}`}${problem.details?.["line"] === void 0 ? "" : ` (line ${String(problem.details["line"])})`}: ${problem.cause}`);
2099
+ return { problems, tally: { derivable: report.tally.derivable, hole: report.tally.hole } };
2100
+ }
2101
+ async function existingSpecIds(storage) {
2102
+ const entries = await storage.listDirectory("specs");
2103
+ return entries.map((entry) => entry.path).filter((path) => !path.includes("/") && path.endsWith(".spec.md") && !path.startsWith("_")).map((path) => path.replace(/\.spec\.md$/, "")).sort();
2104
+ }
2105
+ function buildBriefPrompt(context) {
2106
+ const system = [
2107
+ "Tu r\xE9diges des SPECS de features pour un produit construit avec le framework Ogygie. Une spec est un contrat, relu et sign\xE9 par un humain non d\xE9veloppeur, puis impl\xE9ment\xE9 par une cha\xEEne de g\xE9n\xE9ration : tu \xE9cris pour ce lecteur, en fran\xE7ais, pr\xE9cis, sans jargon.",
2108
+ "",
2109
+ "FORME OBLIGATOIRE \u2014 le fichier entier, et rien d'autre (pas de cl\xF4ture ``` autour, pas de commentaire avant ou apr\xE8s) :",
2110
+ "- un frontmatter YAML entre deux lignes `---`, au format RESTREINT : une cl\xE9 par ligne, scalaires ou tableaux de scalaires entre crochets, jamais d'objet imbriqu\xE9 ; cl\xE9s exactes : id (kebab-case, identique au nom du fichier), status (toujours `draft`), depends_on (tableau d'ids de specs existantes), priority (P0, P1 ou P2), refs (tableau de cha\xEEnes) ; `deferred` seulement si non vide, au format \"<RAISON> blockedBy:<id-kebab>\".",
2111
+ "- puis SIX sections, dans cet ordre exact, titres au niveau `# ` : `# @intent`, `# Comportements`, `# Contrats de donn\xE9es`, `# \xC9tats UI`, `# Non-objectifs`, `# Budget`.",
2112
+ "- Comportements : une liste de triplets `- \xC9TANT DONN\xC9 <contexte>` / `QUAND <action>` / `ALORS <r\xE9sultat observable>`, un par ligne de puce, en fran\xE7ais lisible. Les codes d'erreur, noms de sch\xE9mas et valeurs d'enum sont en ANGLAIS (ex. ORDER_REFERENCE_ALREADY_USED, CreateOrderInputSchema).",
2113
+ "- Contrats de donn\xE9es : esquisse des sch\xE9mas (noms suffix\xE9s Schema, champs et types), jamais de code TypeScript complet.",
2114
+ "- \xC9tats UI : vide / chargement / nominal / erreur, et ce que chacun montre.",
2115
+ "- Non-objectifs : ce que la feature ne fait PAS.",
2116
+ "- Budget : performance, d\xE9pendances (aucune nouvelle sans justification).",
2117
+ "",
2118
+ "R\xC8GLES DE FOND :",
2119
+ "- Tu ne cites une d\xE9cision `D<n>` QUE si elle figure dans la liste des d\xE9cisions connues ci-dessous ; sinon n'en cite aucune.",
2120
+ "- Tu n'inventes ni table, ni route, ni feature existante : tu t'appuies sur la carte du produit ci-dessous ; une rubrique nouvelle nomme ses propres contrats.",
2121
+ "- Tu n'\xE9cris jamais de code ni de design (couleurs, composants) : la spec dit QUOI, jamais COMMENT. Le bloc YAML de `# G\xE9n\xE9ration` n'est pas du code : c'est la d\xE9claration que le g\xE9n\xE9rateur lit.",
2122
+ ...context.grammarCard === null || context.grammarCard === void 0 ? [] : ["- Quand la rubrique apporte des op\xE9rations ou un \xE9cran que le g\xE9n\xE9rateur sait d\xE9river, \xE9cris la section `# G\xE9n\xE9ration` apr\xE8s `# @intent` et ses `# Comportements` dans la grammaire ferm\xE9e (carte ci-dessous) : le brouillon est jug\xE9 par le v\xE9rificateur du g\xE9n\xE9rateur avant d'\xEAtre montr\xE9. Quand elle ne se g\xE9n\xE8re pas, n'\xE9cris pas la section."],
2123
+ "- Tu ne proposes jamais `status: validated` ni `implemented` : valider est le geste de l'humain.",
2124
+ "- Quand la rubrique est un \xC9CRAN (une page du tableau de bord), ajoute apr\xE8s `# Budget` une section `# Design` qui commence par une ligne `Composition : <nom-kebab>` (le nom de la disposition, ex. `liste-d-abord`), puis l'ordre des vues, le pattern du catalogue de chacune (liste, formulaire, \xE9tat vide\u2026) et les \xE9tats mis en avant \u2014 jamais une couleur, jamais un composant invent\xE9.",
2125
+ "",
2126
+ "GABARIT DU REPO (\xE0 respecter \xE0 la lettre) :",
2127
+ context.template,
2128
+ "",
2129
+ "R\xC8GLES PRODUIT (AGENTS.product.md) :",
2130
+ context.productRules ?? "(aucune r\xE8gle produit d\xE9clar\xE9e)",
2131
+ "",
2132
+ "CARTE DU PRODUIT :",
2133
+ context.manifestText,
2134
+ "",
2135
+ `SPECS EXISTANTES (ids) : ${context.specIds.join(", ") || "(aucune)"}`,
2136
+ `D\xC9CISIONS CONNUES (seules citables) : ${context.knownDecisionIds.join(", ") || "(aucune)"}`,
2137
+ ...context.grammarCard === null || context.grammarCard === void 0 ? [] : ["", context.grammarCard]
2138
+ ].join("\n");
2139
+ const user = [
2140
+ context.existingSpec === null ? context.specId === void 0 ? "R\xE9dige une spec NOUVELLE \xE0 partir du brief ci-dessous. Choisis un id kebab-case en fran\xE7ais, court, qui ne soit pas d\xE9j\xE0 pris." : `R\xE9dige une spec NOUVELLE d'id \`${context.specId}\` \xE0 partir du brief ci-dessous.` : `AMENDE la spec existante \`${context.specId ?? ""}\` ci-dessous selon le brief : conserve ce qui n'est pas remis en cause, r\xE9\xE9cris ce qui l'est, et d\xE9cris toujours l'\xE9tat voulu complet (jamais un delta). Garde \`status: draft\`.`,
2141
+ "",
2142
+ ...context.existingSpec === null ? [] : ["SPEC EXISTANTE :", context.existingSpec, ""],
2143
+ "BRIEF :",
2144
+ context.brief.trim()
2145
+ ].join("\n");
2146
+ return { system, user };
2147
+ }
2148
+ function designContextOf(material) {
2149
+ const lines = [];
2150
+ if (material.tokensJson !== null) {
2151
+ try {
2152
+ const tokens2 = JSON.parse(material.tokensJson);
2153
+ for (const [group, value] of Object.entries(tokens2)) {
2154
+ if (group.startsWith("$") || typeof value !== "object" || value === null) continue;
2155
+ lines.push(`- ${group} : ${Array.isArray(value) ? value.join(", ") : Object.keys(value).join(", ")}`);
2156
+ }
2157
+ } catch {
2158
+ lines.push("- (design/tokens.json illisible)");
2159
+ }
2160
+ }
2161
+ const tokens = lines.length === 0 ? "(aucun token d\xE9clar\xE9)" : lines.join("\n");
2162
+ const patterns = material.patternsCatalog === null ? "(aucun catalogue de patterns)" : [...material.patternsCatalog.matchAll(/^## +(.+?) *$\n+([^\n]*)/gmu)].map((match) => `- ${match[1] ?? ""} : ${(match[2] ?? "").split(/(?<=\.)\s/u)[0] ?? ""}`.slice(0, 220)).join("\n") || "(catalogue sans fiche)";
2163
+ const derived = material.derivedBrief === null ? "" : `
2164
+ BRIEF DE DESIGN D\xC9RIV\xC9 DE LA FEATURE :
2165
+ ${material.derivedBrief.length > DERIVED_BRIEF_MAX ? `${material.derivedBrief.slice(0, DERIVED_BRIEF_MAX)} (\u2026)` : material.derivedBrief}
2166
+ `;
2167
+ return `TOKENS DE DESIGN (seuls noms employables) :
2168
+ ${tokens}
2169
+
2170
+ CATALOGUE DES PATTERNS (seules briques d'\xE9cran) :
2171
+ ${patterns}
2172
+ ${derived}`;
2173
+ }
2174
+ function buildCompositionsPrompt(input) {
2175
+ const others = MAX_COMPOSITIONS - 1;
2176
+ return {
2177
+ system: `${input.system}
2178
+
2179
+ ${input.designContext}`,
2180
+ user: [
2181
+ `La spec ci-dessous d\xE9crit un \xE9cran dont la composition retenue par d\xE9faut s'appelle \`${input.composition}\`. Propose jusqu'\xE0 ${others} AUTRES compositions de ce m\xEAme \xE9cran \u2014 un autre ordre des vues, un autre pattern par vue, d'autres \xE9tats mis en avant \u2014 sans changer les comportements, les contrats ni l'id.`,
2182
+ `R\xE9ponds avec, pour chacune, la SPEC COMPL\xC8TE (frontmatter et les six sections, puis \`# Design\` avec sa propre ligne \`Composition : <nom-kebab>\`, un nom diff\xE9rent), les specs s\xE9par\xE9es par une ligne contenant exactement \`${COMPOSITION_SEPARATOR}\`. Rien d'autre. Si une seule disposition a du sens, r\xE9ponds \`${COMPOSITION_SEPARATOR}\` seul.`,
2183
+ "",
2184
+ "SPEC RETENUE :",
2185
+ input.specText.trim()
2186
+ ].join("\n")
2187
+ };
2188
+ }
2189
+ function splitCompositions(answer) {
2190
+ return answer.split(COMPOSITION_SEPARATOR).map((part) => part.trim()).filter((part) => part.includes("---")).map((part) => extractSpecText(part));
2191
+ }
2192
+ function extractSpecText(answer) {
2193
+ const whole = answer.trim();
2194
+ let candidate = whole;
2195
+ if (!whole.startsWith("---")) {
2196
+ const opening = [...answer.matchAll(/```(?:markdown|md|yaml)?[^\n]*\n/g)].find((fence) => answer.slice((fence.index ?? 0) + fence[0].length).trimStart().startsWith("---"));
2197
+ if (opening !== void 0) {
2198
+ const from = (opening.index ?? 0) + opening[0].length;
2199
+ const to = answer.lastIndexOf("```");
2200
+ candidate = (to > from ? answer.slice(from, to) : answer.slice(from)).trim();
2201
+ }
2202
+ }
2203
+ const start = candidate.indexOf("---");
2204
+ const text = start === -1 ? candidate : candidate.slice(start);
2205
+ return text.endsWith("\n") ? text : `${text}
2206
+ `;
2207
+ }
2208
+ function judgeDraft(input) {
2209
+ const parsed = parseSpecDocument(input.content);
2210
+ const problems = [];
2211
+ const specId = parsed.frontmatter?.id ?? null;
2212
+ if (specId === null) {
2213
+ problems.push(...parsed.problems.map((problem) => `frontmatter: ${problem}`));
2214
+ return { specId, problems };
2215
+ }
2216
+ if (input.expectedId !== void 0 && specId !== input.expectedId) {
2217
+ problems.push(`id "${specId}" was asked as "${input.expectedId}"`);
2218
+ }
2219
+ if (!input.isAmendment && input.takenIds.includes(specId)) {
2220
+ problems.push(`id "${specId}" is already taken by an existing spec \u2014 choose another id`);
2221
+ }
2222
+ if (parsed.frontmatter?.status !== "draft") {
2223
+ problems.push(`status must be draft (got ${parsed.frontmatter?.status ?? "none"})`);
2224
+ }
2225
+ try {
2226
+ assertSpecProposable(`specs/${specId}.spec.md`, input.content);
2227
+ } catch (thrown) {
2228
+ if (thrown instanceof OgygieFailure) problems.push(thrown.error.cause ?? thrown.error.message);
2229
+ else throw thrown;
2230
+ }
2231
+ for (const reference of findUnknownDecisionReferences(input.content, input.knownDecisionIds)) {
2232
+ problems.push(`${reference.decision} (line ${reference.line}) is not a declared decision \u2014 cite none, or a known one`);
2233
+ }
2234
+ return { specId, problems: [...new Set(problems)] };
2235
+ }
2236
+ async function draftSpecFromBrief(deps, input) {
2237
+ if (input.specId !== void 0 && !FeatureNameSchema.safeParse(input.specId).success) {
2238
+ throwOgygieError(
2239
+ {
2240
+ code: "CONTRACT_VALIDATION_FAILED",
2241
+ message: "The spec id must be kebab-case.",
2242
+ file: null,
2243
+ cause: `specId=${input.specId}`,
2244
+ suggestedFix: "Use lowercase words separated by hyphens, e.g. suivi-des-commandes."
2245
+ },
2246
+ 400
2247
+ );
2248
+ }
2249
+ const template = await readIfPresent(deps.storage, TEMPLATE_PATH2);
2250
+ if (template === null) {
2251
+ throwOgygieError({
2252
+ code: "SPEC_TEMPLATE_NOT_FOUND",
2253
+ message: "specs/_TEMPLATE.spec.md is missing \u2014 the model writes from it.",
2254
+ file: TEMPLATE_PATH2,
2255
+ cause: "template file unreadable",
2256
+ suggestedFix: "Restore specs/_TEMPLATE.spec.md from the framework skeleton."
2257
+ });
2258
+ }
2259
+ const [productRules, manifest, specIds] = await Promise.all([
2260
+ readIfPresent(deps.storage, PRODUCT_RULES_PATH),
2261
+ manifestSummary(deps.storage),
2262
+ existingSpecIds(deps.storage)
2263
+ ]);
2264
+ const knownDecisionIds = (await Promise.all(DECISION_FILES.map((file) => readIfPresent(deps.storage, file)))).flatMap((content) => parseKnownDecisionIds(content ?? ""));
2265
+ const existingSpec = input.specId !== void 0 ? await readIfPresent(deps.storage, `specs/${input.specId}.spec.md`) : null;
2266
+ const isAmendment = existingSpec !== null;
2267
+ const prompt = buildBriefPrompt({
2268
+ template,
2269
+ productRules,
2270
+ manifestText: manifest.text,
2271
+ grammarCard: manifest.generation === null ? null : generationGrammarCard(manifest.generation),
2272
+ specIds,
2273
+ knownDecisionIds,
2274
+ existingSpec,
2275
+ specId: input.specId,
2276
+ brief: input.brief
2277
+ });
2278
+ const usage = [];
2279
+ let previous;
2280
+ let firstProblems = [];
2281
+ let maxAttempts = MAX_ATTEMPTS;
2282
+ for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
2283
+ const answer = await deps.writer.write({ ...prompt, previous });
2284
+ usage.push({ model: answer.model, ...answer.usage });
2285
+ const content = extractSpecText(answer.text);
2286
+ const judged = judgeDraft({
2287
+ content,
2288
+ expectedId: input.specId,
2289
+ knownDecisionIds,
2290
+ takenIds: specIds,
2291
+ isAmendment
2292
+ });
2293
+ const generationJudgement = judged.problems.length === 0 && judged.specId !== null ? await judgeGeneration({ content, specPath: `specs/${judged.specId}.spec.md`, generation: manifest.generation, storage: deps.storage, features: manifest.features }) : { problems: [], tally: null };
2294
+ judged.problems.push(...generationJudgement.problems);
2295
+ if (generationJudgement.tally !== null) maxAttempts = MAX_ATTEMPTS_GENERATED;
2296
+ deps.log(JSON.stringify({ action: "brief", attempt, specId: judged.specId, problems: judged.problems.length, generated: generationJudgement.tally !== null, ...answer.usage }));
2297
+ if (judged.problems.length === 0 && judged.specId !== null) {
2298
+ const specId = judged.specId;
2299
+ const briefForMessage = input.brief.length > BRIEF_IN_MESSAGE_MAX ? `${input.brief.slice(0, BRIEF_IN_MESSAGE_MAX)} (\u2026)` : input.brief;
2300
+ const composition = isAmendment ? null : compositionOfSpec(content);
2301
+ const compositions = [];
2302
+ if (composition !== null) {
2303
+ const [tokensJson, patternsCatalog, derivedBrief] = await Promise.all([
2304
+ readIfPresent(deps.storage, DESIGN_TOKENS_PATH),
2305
+ readIfPresent(deps.storage, PATTERNS_CATALOG_PATH),
2306
+ readIfPresent(deps.storage, `${DESIGN_BRIEFS_DIR}/${specId}.md`)
2307
+ ]);
2308
+ let answerText = "";
2309
+ try {
2310
+ const answer2 = await deps.writer.write(buildCompositionsPrompt({ system: prompt.system, specText: content, composition, designContext: designContextOf({ tokensJson, patternsCatalog, derivedBrief }) }));
2311
+ usage.push({ model: answer2.model, ...answer2.usage });
2312
+ answerText = answer2.text;
2313
+ } catch (thrown) {
2314
+ deps.log(JSON.stringify({ action: "composition", specId, outcome: thrown instanceof OgygieFailure ? thrown.error.code : "error" }));
2315
+ }
2316
+ const seen = /* @__PURE__ */ new Set([composition]);
2317
+ for (const candidate of splitCompositions(answerText)) {
2318
+ if (seen.size >= MAX_COMPOSITIONS) break;
2319
+ const name = compositionOfSpec(candidate);
2320
+ const verdict = judgeDraft({ content: candidate, expectedId: specId, knownDecisionIds, takenIds: specIds, isAmendment: false });
2321
+ const problems = name === null ? ["# Design names no composition"] : seen.has(name) ? [`composition "${name}" is already proposed`] : verdict.problems;
2322
+ deps.log(JSON.stringify({ action: "composition", specId, composition: name, problems: problems.length }));
2323
+ if (problems.length > 0 || name === null) continue;
2324
+ seen.add(name);
2325
+ await deps.storage.write(`specs/${specId}.spec.md`, candidate, `spec: composition ${name} pour ${specId}, propos\xE9e par le mod\xE8le
2326
+
2327
+ Une autre disposition du m\xEAme \xE9cran ; la composition retenue par d\xE9faut suit dans le commit suivant.
2328
+
2329
+ Brief :
2330
+ ${briefForMessage}`);
2331
+ compositions.push(name);
2332
+ }
2333
+ compositions.unshift(composition);
2334
+ }
2335
+ await deps.storage.write(
2336
+ `specs/${specId}.spec.md`,
2337
+ content,
2338
+ `spec: ${isAmendment ? "amend" : "draft"} ${specId} depuis un brief
2339
+
2340
+ Brief :
2341
+ ${briefForMessage}`
2342
+ );
2343
+ return {
2344
+ specId,
2345
+ attempts: attempt,
2346
+ problemsFixed: firstProblems,
2347
+ isNewFeature: !manifest.featureIds.has(specId),
2348
+ isAmendment,
2349
+ compositions,
2350
+ generation: generationJudgement.tally,
2351
+ usage
2352
+ };
2353
+ }
2354
+ if (attempt === 1) firstProblems = judged.problems;
2355
+ previous = { draft: answer.text, problems: judged.problems };
2356
+ }
2357
+ return throwOgygieError(
2358
+ {
2359
+ code: "SPEC_DRAFT_REJECTED",
2360
+ message: `The model's draft still fails the repo's gates after ${maxAttempts} attempts; nothing was written.`,
2361
+ file: input.specId === void 0 ? null : `specs/${input.specId}.spec.md`,
2362
+ cause: (previous?.problems ?? []).join(" ; "),
2363
+ suggestedFix: "Rephrase the brief (one rubric, concrete behaviours, no code), or write the spec by hand from the template.",
2364
+ details: { problems: previous?.problems ?? [], usage }
2365
+ },
2366
+ 422
2367
+ );
2368
+ }
2369
+
2370
+ // src/server/hosted/brief-routes.ts
2371
+ async function registerBriefRoutes(app, deps) {
2372
+ const route = app.withTypeProvider();
2373
+ route.route({
2374
+ method: "POST",
2375
+ url: "/api/briefs",
2376
+ schema: { body: BriefInputSchema, response: { 201: BriefOutputSchema } },
2377
+ handler: async (request, reply) => {
2378
+ const user = hostedUserOf(request);
2379
+ requireRole(user, "author", "write a brief");
2380
+ const storage = deps.storageFor(user);
2381
+ const result = await draftSpecFromBrief(
2382
+ { storage, writer: deps.writer, log: deps.log },
2383
+ { brief: request.body.brief, specId: request.body.specId }
2384
+ );
2385
+ const proposal = "lastProposal" in storage ? storage.lastProposal : null;
2386
+ if (deps.usage !== void 0 && proposal !== null) {
2387
+ const at = new Date((deps.now ?? Date.now)()).toISOString();
2388
+ try {
2389
+ await deps.usage.appendUsage(
2390
+ proposal.pullRequestNumber,
2391
+ result.usage.map((usage, index) => ({ at, action: "brief", attempt: index + 1, model: usage.model, inputTokens: usage.inputTokens, outputTokens: usage.outputTokens, actor: user.email, specId: result.specId }))
2392
+ );
2393
+ } catch (thrown) {
2394
+ deps.log(JSON.stringify({ action: "usage-journal", specId: result.specId, outcome: thrown instanceof Error ? thrown.message : String(thrown) }));
2395
+ }
2396
+ }
2397
+ return reply.code(201).send(result);
2398
+ }
2399
+ });
2400
+ }
2401
+
2402
+ // src/server/hosted/previews.ts
2403
+ var PREVIEWS_DIR = "previews";
2404
+ var STATE_NAME = /^[a-z][a-z0-9-]*$/;
2405
+ var STATE_ORDER = ["empty", "loading", "nominal", "error"];
2406
+ function previewsDirOf(specId, composition = null) {
2407
+ return composition === null ? `${PREVIEWS_DIR}/${specId}` : `${PREVIEWS_DIR}/${specId}/${composition}`;
2408
+ }
2409
+ function previewSpecIdOf(path) {
2410
+ const match = /^previews\/([a-z][a-z0-9]*(?:-[a-z0-9]+)*)\//.exec(path);
2411
+ return match?.[1] ?? null;
2412
+ }
2413
+ function orderStates(states) {
2414
+ return [...states].sort((a, b) => {
2415
+ const ia = STATE_ORDER.indexOf(a);
2416
+ const ib = STATE_ORDER.indexOf(b);
2417
+ if (ia !== -1 || ib !== -1) return (ia === -1 ? STATE_ORDER.length : ia) - (ib === -1 ? STATE_ORDER.length : ib) || a.localeCompare(b);
2418
+ return a.localeCompare(b);
2419
+ });
2420
+ }
2421
+ function orderedPages(pages) {
2422
+ const byState = new Map(pages.map((entry) => [entry.state, entry]));
2423
+ return orderStates([...byState.keys()]).map((state) => byState.get(state));
2424
+ }
2425
+ async function listPreviews(storage, specId, composition = null) {
2426
+ const dir = previewsDirOf(specId, composition);
2427
+ const entries = await storage.listDirectory(dir);
2428
+ return orderedPages(
2429
+ entries.filter((entry) => !entry.path.includes("/") && entry.path.endsWith(".html")).map((entry) => ({ state: entry.path.replace(/\.html$/, ""), path: `${dir}/${entry.path}`, sizeBytes: entry.sizeBytes })).filter((entry) => STATE_NAME.test(entry.state))
2430
+ );
2431
+ }
2432
+ async function listPreviewCompositions(storage, specId) {
2433
+ const dir = previewsDirOf(specId);
2434
+ const entries = await storage.listDirectory(dir);
2435
+ const byName = /* @__PURE__ */ new Map();
2436
+ for (const entry of entries) {
2437
+ const segments = entry.path.split("/");
2438
+ if (segments.length !== 2) continue;
2439
+ const [name, file] = segments;
2440
+ if (name === void 0 || file === void 0 || !STATE_NAME.test(name) || !file.endsWith(".html")) continue;
2441
+ const state = file.replace(/\.html$/, "");
2442
+ if (!STATE_NAME.test(state)) continue;
2443
+ const pages = byName.get(name) ?? [];
2444
+ pages.push({ state, path: `${dir}/${name}/${file}`, sizeBytes: entry.sizeBytes });
2445
+ byName.set(name, pages);
2446
+ }
2447
+ return [...byName.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([name, pages]) => ({ name, previews: orderedPages(pages) }));
2448
+ }
2449
+ async function readPreview(storage, specId, state, composition = null) {
2450
+ if (composition !== null && !STATE_NAME.test(composition)) {
2451
+ throwOgygieError(
2452
+ { code: "PREVIEW_NOT_FOUND", message: `"${composition}" is not a composition name.`, file: null, cause: "a composition is named in kebab-case", suggestedFix: "Pick a composition from GET /api/specs/<id>/previews." },
2453
+ 404
2454
+ );
2455
+ }
2456
+ if (!STATE_NAME.test(state)) {
2457
+ throwOgygieError(
2458
+ { code: "PREVIEW_NOT_FOUND", message: `"${state}" is not a preview state name.`, file: null, cause: "state must be lowercase letters, digits and hyphens", suggestedFix: "Pick a state from GET /api/specs/<id>/previews." },
2459
+ 404
2460
+ );
2461
+ }
2462
+ const path = `${previewsDirOf(specId, composition)}/${state}.html`;
2463
+ if (!await storage.exists(path)) {
2464
+ throwOgygieError(
2465
+ { code: "PREVIEW_NOT_FOUND", message: `Spec "${specId}" has no preview for state "${state}"${composition === null ? "" : ` in composition "${composition}"`}.`, file: path, cause: "no such file on the spec's working copy", suggestedFix: "The speculate job renders previews when the generator knows the screen; see GET /api/specs/<id>/previews for what exists." },
2466
+ 404
2467
+ );
2468
+ }
2469
+ return storage.readFile(path);
2470
+ }
2471
+
2472
+ // src/server/hosted/github-storage.ts
2473
+ var SPEC_PATH = /^specs\/([a-z][a-z0-9]*(?:-[a-z0-9]+)*)\.spec\.md$/;
2474
+ function specIdOfPath2(path) {
2475
+ return SPEC_PATH.exec(path)?.[1] ?? null;
2476
+ }
2477
+ function specBranchOf(specId) {
2478
+ return `spec/${specId}`;
2479
+ }
2480
+ function slug(path) {
2481
+ return path.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/(^-|-$)/g, "");
2482
+ }
2483
+ function createHostedSpecStorage(deps) {
2484
+ const defaultReader = deps.readerFor(deps.github.defaultBranch);
2485
+ const branchReaders = /* @__PURE__ */ new Map();
2486
+ let branchesPromise = null;
2487
+ const branches = () => {
2488
+ branchesPromise ??= deps.listSpecBranches().then((names) => new Set(names));
2489
+ return branchesPromise;
2490
+ };
2491
+ const readerOfBranch = (branch) => {
2492
+ let reader = branchReaders.get(branch);
2493
+ if (reader === void 0) {
2494
+ reader = deps.readerFor(branch);
2495
+ branchReaders.set(branch, reader);
2496
+ }
2497
+ return reader;
2498
+ };
2499
+ async function readerOf(path) {
2500
+ const specId = specIdOfPath2(path) ?? previewSpecIdOf(path);
2501
+ if (specId === null) return defaultReader;
2502
+ const branch = specBranchOf(specId);
2503
+ return (await branches()).has(branch) ? readerOfBranch(branch) : defaultReader;
2504
+ }
2505
+ const storage = {
2506
+ lastProposal: null,
2507
+ async readFile(path) {
2508
+ return (await readerOf(path)).readFile(path);
2509
+ },
2510
+ async exists(path) {
2511
+ return (await readerOf(path)).exists(path);
2512
+ },
2513
+ async currentRef() {
2514
+ return defaultReader.currentRef();
2515
+ },
2516
+ async listDirectory(path) {
2517
+ const entries = await defaultReader.listDirectory(path);
2518
+ if (path.replace(/\/$/, "") !== "specs") return entries;
2519
+ const known = new Set(entries.map((entry) => entry.path));
2520
+ const extra = [];
2521
+ for (const branch of await branches()) {
2522
+ const specId = branch.slice("spec/".length);
2523
+ const fileName = `${specId}.spec.md`;
2524
+ if (known.has(fileName)) continue;
2525
+ const reader = readerOfBranch(branch);
2526
+ const specPath = `specs/${fileName}`;
2527
+ if (!await reader.exists(specPath)) continue;
2528
+ const content = await reader.readFile(specPath);
2529
+ extra.push({ path: fileName, sizeBytes: Buffer.byteLength(content, "utf8") });
2530
+ }
2531
+ return [...entries, ...extra].sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0);
2532
+ },
2533
+ async write(path, content, message) {
2534
+ const specId = specIdOfPath2(path);
2535
+ const startedAt = deps.now();
2536
+ if (specId === null) {
2537
+ requireRole(deps.user, "operator", `write ${path}`);
2538
+ } else if (message.startsWith("spec: validate ")) {
2539
+ requireRole(deps.user, "approver", `validate ${specId}`);
2540
+ } else {
2541
+ requireRole(deps.user, "author", `write ${path}`);
2542
+ }
2543
+ const branch = specId === null ? `host/${deps.repoId}/${slug(path)}` : specBranchOf(specId);
2544
+ let outcome = "ok";
2545
+ let pullRequestUrl = null;
2546
+ try {
2547
+ const proposal = await proposeChange(
2548
+ { id: deps.repoId, github: deps.github, adapter: storage },
2549
+ {
2550
+ clientFor: () => deps.client,
2551
+ originOf: (proposedPath) => `Propos\xE9 via le studio Ogygie h\xE9berg\xE9 par ${deps.user.displayName} <${deps.user.email}> (repo \`${deps.repoId}\`, chemin \`${proposedPath}\`).`,
2552
+ authorOf: () => ({ name: deps.user.displayName, email: deps.user.email }),
2553
+ branchFor: () => branch,
2554
+ readFrom: "branch",
2555
+ now: deps.now,
2556
+ rateLimitPerHour: deps.rateLimitPerHour,
2557
+ recentProposals: deps.recentProposals
2558
+ },
2559
+ { path, content, message }
2560
+ );
2561
+ pullRequestUrl = proposal.pullRequestUrl;
2562
+ storage.lastProposal = proposal;
2563
+ (await branches()).add(proposal.branch);
2564
+ branchReaders.delete(proposal.branch);
2565
+ } catch (thrown) {
2566
+ outcome = thrown instanceof Error && "error" in thrown ? String(thrown.error.code) : "error";
2567
+ throw thrown;
2568
+ } finally {
2569
+ deps.log(
2570
+ JSON.stringify({
2571
+ action: message.split(" ")[1] ?? "write",
2572
+ specId,
2573
+ actor: deps.user.email,
2574
+ outcome,
2575
+ pullRequestUrl,
2576
+ durationMs: deps.now() - startedAt
2577
+ })
2578
+ );
2579
+ }
2580
+ }
2581
+ };
2582
+ return storage;
2583
+ }
2584
+ function createGithubSpecBranchLister(input) {
2585
+ const apiBase = input.apiBase ?? "https://api.github.com";
2586
+ return async () => {
2587
+ const response = await input.fetchImpl(
2588
+ `${apiBase}/repos/${input.github.owner}/${input.github.repo}/git/matching-refs/heads/spec/`,
2589
+ {
2590
+ method: "GET",
2591
+ headers: {
2592
+ accept: "application/vnd.github+json",
2593
+ "x-github-api-version": "2022-11-28",
2594
+ "user-agent": "ogygie-studio"
2595
+ }
2596
+ }
2597
+ );
2598
+ if (!response.ok) {
2599
+ return throwOgygieError({
2600
+ code: "GITHUB_UNAVAILABLE",
2601
+ message: `GitHub answered ${response.status} when listing spec/* branches.`,
2602
+ file: null,
2603
+ cause: `status=${response.status}`,
2604
+ suggestedFix: "Retry later; check the app installation grants contents on this repo."
2605
+ });
2606
+ }
2607
+ const refs = await response.json();
2608
+ return refs.map((entry) => entry.ref.replace(/^refs\/heads\//, "")).filter((name) => name.startsWith("spec/"));
2609
+ };
2610
+ }
2611
+
2612
+ // src/server/hosted/merge.ts
2613
+ var NEEDS_OPERATOR_LABEL = "needs-operator";
2614
+ function requiredRoleFor(mergePolicy) {
2615
+ return mergePolicy === "client" ? "approver" : "operator";
2616
+ }
2617
+ async function specStatusOf(storage, specId) {
2618
+ const path = `specs/${specId}.spec.md`;
2619
+ if (!await storage.exists(path)) return "absent";
2620
+ const parsed = parseSpecDocument(await storage.readFile(path));
2621
+ return parsed.frontmatter?.status ?? "unreadable";
2622
+ }
2623
+ async function pullRequestStatus(deps, specId) {
2624
+ const branch = specBranchOf(specId);
2625
+ const requiredRole = requiredRoleFor(deps.mergePolicy);
2626
+ const [specStatus, head] = await Promise.all([specStatusOf(deps.storage, specId), deps.pulls.findOpenPullRequest(branch)]);
2627
+ if (head === null) {
2628
+ return { specId, branch, pullRequest: null, specStatus, checks: "none", checkRuns: [], mergeable: null, requiredRole, canMerge: false, blockers: ["Aucune pull request ouverte pour cette spec : une sauvegarde en ouvre une."], labels: [], needsOperator: false };
2629
+ }
2630
+ const [pull, checks] = await Promise.all([deps.pulls.getPullRequest(head.number), deps.pulls.getChecks(head.headSha)]);
2631
+ const needsOperator = head.labels.includes(NEEDS_OPERATOR_LABEL);
2632
+ const blockers = [];
2633
+ if (needsOperator) blockers.push("En attente de l'op\xE9rateur : le premier schemas.ts de cette feature nouvelle a \xE9t\xE9 refus\xE9 deux fois par les gates (\xE9tiquette needs-operator).");
2634
+ if (specStatus !== "validated") blockers.push(specStatus === "draft" ? "La spec est encore en draft : elle doit \xEAtre valid\xE9e (sign\xE9e) d'abord." : `La spec est ${specStatus} sur la branche.`);
2635
+ if (checks.state === "red") blockers.push("Un check de la CI est rouge sur la branche.");
2636
+ if (checks.state === "pending") blockers.push("La CI tourne encore sur la branche.");
2637
+ if (checks.state === "none") blockers.push("Aucun check n'a encore couru sur la branche.");
2638
+ if (pull.mergeable === false) blockers.push("La branche est en conflit avec la branche par d\xE9faut : \xE0 r\xE9g\xE9n\xE9rer (op\xE9rateur).");
2639
+ if (pull.mergeable === null) blockers.push("GitHub calcule encore si la branche est fusionnable.");
2640
+ return {
2641
+ specId,
2642
+ branch,
2643
+ pullRequest: { number: pull.number, url: pull.url, headSha: pull.headSha, title: head.title },
2644
+ specStatus,
2645
+ checks: checks.state,
2646
+ checkRuns: checks.runs,
2647
+ mergeable: pull.mergeable,
2648
+ requiredRole,
2649
+ canMerge: blockers.length === 0,
2650
+ blockers,
2651
+ labels: head.labels,
2652
+ needsOperator
2653
+ };
2654
+ }
2655
+ async function listSpecPullRequests(pulls) {
2656
+ const heads = await pulls.listOpenSpecPullRequests();
2657
+ return Promise.all(
2658
+ heads.map(async (head) => {
2659
+ const checks = await pulls.getChecks(head.headSha);
2660
+ return { specId: head.branch.slice("spec/".length), number: head.number, url: head.url, checks: checks.state, needsOperator: head.labels.includes(NEEDS_OPERATOR_LABEL) };
2661
+ })
2662
+ );
2663
+ }
2664
+ async function mergeSpecPullRequest(deps, user, specId) {
2665
+ const requiredRole = requiredRoleFor(deps.mergePolicy);
2666
+ requireRole(user, requiredRole, `merge ${specId}`);
2667
+ const status = await pullRequestStatus(deps, specId);
2668
+ const startedAt = deps.now();
2669
+ const refuse2 = (code, message, httpStatus) => throwOgygieError(
2670
+ { code, message, file: `specs/${specId}.spec.md`, cause: status.blockers.join(" ; "), suggestedFix: "Lift the listed blockers, then merge again \u2014 the rule is the same one the spec-signed check and the branch protection hold.", details: { blockers: status.blockers } },
2671
+ httpStatus
2672
+ );
2673
+ if (status.pullRequest === null) refuse2("PULL_REQUEST_NOT_FOUND", `No open pull request carries spec "${specId}".`, 404);
2674
+ if (status.needsOperator) refuse2("SPEC_NEEDS_OPERATOR", `Spec "${specId}" awaits the operator: its first schemas module failed the gates twice (label ${NEEDS_OPERATOR_LABEL}).`, 409);
2675
+ if (status.specStatus !== "validated") refuse2("SPEC_NOT_VALIDATED", `Spec "${specId}" is not validated: nothing merges before the signature.`, 409);
2676
+ if (status.checks !== "green") refuse2("CHECKS_NOT_GREEN", `The checks of spec "${specId}" are ${status.checks}.`, 409);
2677
+ if (status.mergeable !== true) refuse2("PULL_REQUEST_CONFLICTING", `The branch of spec "${specId}" is not mergeable.`, 409);
2678
+ const pull = status.pullRequest;
2679
+ if (pull === null) return refuse2("PULL_REQUEST_NOT_FOUND", "unreachable", 404);
2680
+ let outcome = "ok";
2681
+ let mergeSha = "";
2682
+ try {
2683
+ const merged = await deps.pulls.mergePullRequest(pull.number, {
2684
+ title: `spec: merge ${specId} \u2014 approuv\xE9e par ${user.displayName}`,
2685
+ message: `Spec ${specId} fusionn\xE9e depuis le studio h\xE9berg\xE9 par ${user.displayName} <${user.email}> (pull request #${pull.number}).`
2686
+ });
2687
+ mergeSha = merged.sha;
2688
+ await deps.pulls.deleteBranch(status.branch);
2689
+ } catch (thrown) {
2690
+ outcome = thrown instanceof Error && "error" in thrown ? String(thrown.error.code) : "error";
2691
+ throw thrown;
2692
+ } finally {
2693
+ deps.log(JSON.stringify({ action: "merge", specId, actor: user.email, outcome, pullRequestUrl: pull.url, durationMs: deps.now() - startedAt }));
2694
+ }
2695
+ return { specId, pullRequestNumber: pull.number, mergeSha, mergedBy: user.email, mergedAt: new Date(deps.now()).toISOString() };
2696
+ }
2697
+
2698
+ // src/server/hosted/merge-routes.ts
2699
+ async function registerMergeRoutes(app, deps) {
2700
+ const route = app.withTypeProvider();
2701
+ const mergeDepsFor = (user) => ({
2702
+ pulls: deps.pulls,
2703
+ storage: deps.storageFor(user),
2704
+ mergePolicy: deps.config.mergePolicy,
2705
+ now: deps.now,
2706
+ log: deps.log
2707
+ });
2708
+ route.route({
2709
+ method: "GET",
2710
+ url: "/api/specs/:specId/pull-request",
2711
+ schema: { params: SpecParamsSchema, response: { 200: PullRequestStatusSchema } },
2712
+ handler: async (request, reply) => {
2713
+ const user = hostedUserOf(request);
2714
+ return reply.code(200).send(await pullRequestStatus(mergeDepsFor(user), request.params.specId));
2715
+ }
2716
+ });
2717
+ route.route({
2718
+ method: "GET",
2719
+ url: "/api/pull-requests",
2720
+ schema: { response: { 200: SpecPullRequestsSchema } },
2721
+ handler: async (request, reply) => {
2722
+ hostedUserOf(request);
2723
+ return reply.code(200).send({ pullRequests: await listSpecPullRequests(deps.pulls) });
2724
+ }
2725
+ });
2726
+ route.route({
2727
+ method: "POST",
2728
+ url: "/api/specs/:specId/merge",
2729
+ schema: { params: SpecParamsSchema, response: { 200: MergeOutputSchema } },
2730
+ handler: async (request, reply) => {
2731
+ const user = hostedUserOf(request);
2732
+ return reply.code(200).send(await mergeSpecPullRequest(mergeDepsFor(user), user, request.params.specId));
2733
+ }
2734
+ });
2735
+ }
2736
+
2737
+ // src/server/hosted/promotion.ts
2738
+ var ENVIRONMENT_REFS = {
2739
+ staging: "env/staging",
2740
+ production: "env/production"
2741
+ };
2742
+ var SHA = /^[0-9a-f]{40}$/;
2743
+ var PULL_NUMBER = /\(#(\d+)\)/;
2744
+ var MERGED_SPEC = /^spec: merge ([a-z][a-z0-9]*(?:-[a-z0-9]+)*)/;
2745
+ var DESTRUCTIVE_MIGRATION = /migration destructive/i;
2746
+ async function environmentsStatus(deps) {
2747
+ const [mainSha, stagingSha, productionSha, stagingDeployment, productionDeployment, releases] = await Promise.all([
2748
+ deps.deploy.getRefSha(deps.defaultBranch),
2749
+ deps.deploy.getRefSha(ENVIRONMENT_REFS.staging),
2750
+ deps.deploy.getRefSha(ENVIRONMENT_REFS.production),
2751
+ deps.deploy.latestDeployment("staging"),
2752
+ deps.deploy.latestDeployment("production"),
2753
+ deps.deploy.listTags("release/")
2754
+ ]);
2755
+ const deploymentOf = (deployment) => deployment === null ? null : { id: deployment.id, state: deployment.state, createdAt: deployment.createdAt };
2756
+ return {
2757
+ environments: [
2758
+ { name: "development", ref: deps.defaultBranch, sha: mainSha, deployment: null },
2759
+ { name: "staging", ref: ENVIRONMENT_REFS.staging, sha: stagingSha, deployment: deploymentOf(stagingDeployment) },
2760
+ { name: "production", ref: ENVIRONMENT_REFS.production, sha: productionSha, deployment: deploymentOf(productionDeployment) }
2761
+ ],
2762
+ releases: releases.sort().reverse()
2763
+ };
2764
+ }
2765
+ function pullRequestsOf(commits) {
2766
+ const included = [];
2767
+ for (const commit of commits) {
2768
+ const firstLine = commit.message.split("\n")[0] ?? "";
2769
+ const number = PULL_NUMBER.exec(firstLine)?.[1];
2770
+ if (number === void 0) continue;
2771
+ included.push({
2772
+ number: Number.parseInt(number, 10),
2773
+ specId: MERGED_SPEC.exec(firstLine)?.[1] ?? null,
2774
+ message: firstLine,
2775
+ authoredAt: commit.authoredAt
2776
+ });
2777
+ }
2778
+ return included;
2779
+ }
2780
+ function nextReleaseTag(existing, nowMs) {
2781
+ const day = new Date(nowMs).toISOString().slice(0, 10);
2782
+ const prefix = `release/${day}.`;
2783
+ const taken = existing.filter((tag) => tag.startsWith(prefix)).length;
2784
+ return `${prefix}${taken + 1}`;
2785
+ }
2786
+ function refuse(code, message, cause, suggestedFix, httpStatus = 409) {
2787
+ return throwOgygieError({ code, message, file: null, cause, suggestedFix }, httpStatus);
2788
+ }
2789
+ async function promote(deps, user, input) {
2790
+ const rollback = input.rollback === true;
2791
+ requireRole(user, rollback ? "operator" : "approver", `promote ${input.environment}`);
2792
+ if (!SHA.test(input.sha)) {
2793
+ refuse("CONTRACT_VALIDATION_FAILED", "A promotion names a full commit sha.", `sha=${input.sha}`, "Give the 40-character sha shown on the environments screen.", 400);
2794
+ }
2795
+ if (rollback && (input.reason === void 0 || input.reason.trim().length < 10)) {
2796
+ refuse("CONTRACT_VALIDATION_FAILED", "A rollback carries its reason.", "rollback without reason", "Say why the environment goes back (ten characters at least).", 400);
2797
+ }
2798
+ const ref = ENVIRONMENT_REFS[input.environment];
2799
+ const [currentSha, targetSha, mainSha] = await Promise.all([
2800
+ deps.deploy.getRefSha(ref),
2801
+ deps.deploy.getCommitSha(input.sha),
2802
+ deps.deploy.getRefSha(deps.defaultBranch)
2803
+ ]);
2804
+ if (currentSha === null) {
2805
+ refuse("HOST_ENVIRONMENT_REF_MISSING", `The ref ${ref} does not exist on the repo.`, `ref=${ref}`, "Create and protect env/staging and env/production (docs/HOSTED-STUDIO.md), then retry.", 500);
2806
+ }
2807
+ if (targetSha === null) {
2808
+ refuse("CONTRACT_VALIDATION_FAILED", `Commit ${input.sha} does not exist on the repo.`, `sha=${input.sha}`, "Promote a sha the environments screen shows.", 400);
2809
+ }
2810
+ if (input.environment === "staging") {
2811
+ const inMain = mainSha === null ? "diverged" : (await deps.deploy.compare(targetSha, mainSha)).status;
2812
+ if (inMain !== "ahead" && inMain !== "identical") {
2813
+ refuse("PROMOTION_ORDER_VIOLATED", `Commit ${targetSha.slice(0, 7)} is not in the history of ${deps.defaultBranch}.`, `compare(${targetSha.slice(0, 7)}...${deps.defaultBranch})=${inMain}`, "Staging only receives what the default branch has merged.");
2814
+ }
2815
+ } else {
2816
+ const stagingSha = await deps.deploy.getRefSha(ENVIRONMENT_REFS.staging);
2817
+ if (stagingSha !== targetSha) {
2818
+ refuse("PROMOTION_ORDER_VIOLATED", `Production only receives the sha staging holds (${stagingSha?.slice(0, 7) ?? "none"}), not ${targetSha.slice(0, 7)}.`, `staging=${stagingSha ?? "<absent>"} asked=${targetSha}`, "Promote that sha to staging first, see it there, then to production \u2014 the order is the rule, to the sha.");
2819
+ }
2820
+ }
2821
+ const forward = await deps.deploy.compare(currentSha, targetSha);
2822
+ if (forward.status === "identical") {
2823
+ refuse("PROMOTION_ORDER_VIOLATED", `${input.environment} already holds ${targetSha.slice(0, 7)}.`, "nothing to move", "Pick a newer sha, or nothing.");
2824
+ }
2825
+ if (forward.status !== "ahead" && !rollback) {
2826
+ refuse("PROMOTION_NOT_FAST_FORWARD", `Moving ${input.environment} to ${targetSha.slice(0, 7)} is not a fast-forward (${forward.status}).`, `compare(${currentSha.slice(0, 7)}...${targetSha.slice(0, 7)})=${forward.status}`, "A step back is an explicit rollback, reserved to the operator, with its reason.");
2827
+ }
2828
+ const includedPullRequests = rollback ? [] : pullRequestsOf(forward.commits);
2829
+ let destructiveMigration = false;
2830
+ for (const pull of includedPullRequests) {
2831
+ if (DESTRUCTIVE_MIGRATION.test(await deps.deploy.getPullRequestBody(pull.number))) destructiveMigration = true;
2832
+ }
2833
+ if (destructiveMigration && input.environment === "production") {
2834
+ requireRole(user, "operator", `promote to production a release with a destructive migration`);
2835
+ }
2836
+ const startedAt = deps.now();
2837
+ let outcome = "ok";
2838
+ let deploymentId = 0;
2839
+ let releaseTag = null;
2840
+ try {
2841
+ await deps.deploy.updateRef(ref, targetSha, rollback);
2842
+ const description = rollback ? `Retour arri\xE8re de ${input.environment} par ${user.displayName} : ${input.reason ?? ""}` : `Promotion de ${input.environment} par ${user.displayName} (${includedPullRequests.length} PR)`;
2843
+ deploymentId = (await deps.deploy.createDeployment({
2844
+ sha: targetSha,
2845
+ environment: input.environment,
2846
+ description: description.slice(0, 140),
2847
+ payload: { actor: user.email, fromSha: currentSha, rollback, reason: input.reason ?? null, includedPullRequests, destructiveMigration }
2848
+ })).id;
2849
+ if (input.environment === "production" && !rollback) {
2850
+ releaseTag = nextReleaseTag(await deps.deploy.listTags("release/"), deps.now());
2851
+ const lines = includedPullRequests.map((pull) => `- #${pull.number} ${pull.specId ?? ""} ${pull.authoredAt.slice(0, 10)} \u2014 ${pull.message}`.replace(/\s+/g, " "));
2852
+ await deps.deploy.createTag({
2853
+ name: releaseTag,
2854
+ sha: targetSha,
2855
+ message: `Release ${releaseTag} \u2014 mise en production par ${user.displayName}
2856
+
2857
+ ${lines.join("\n") || "(aucune pull request list\xE9e)"}
2858
+ `,
2859
+ tagger: { name: user.displayName, email: user.email }
2860
+ });
2861
+ }
2862
+ } catch (thrown) {
2863
+ outcome = thrown instanceof Error && "error" in thrown ? String(thrown.error.code) : "error";
2864
+ throw thrown;
2865
+ } finally {
2866
+ deps.log(JSON.stringify({ action: rollback ? "rollback" : "promote", environment: input.environment, actor: user.email, outcome, fromSha: currentSha, toSha: targetSha, releaseTag, durationMs: deps.now() - startedAt }));
2867
+ }
2868
+ return {
2869
+ environment: input.environment,
2870
+ fromSha: currentSha,
2871
+ toSha: targetSha,
2872
+ actor: user.email,
2873
+ at: new Date(deps.now()).toISOString(),
2874
+ deploymentId,
2875
+ releaseTag,
2876
+ includedPullRequests,
2877
+ destructiveMigration,
2878
+ rollback
2879
+ };
2880
+ }
2881
+
2882
+ // src/server/hosted/promotion-routes.ts
2883
+ async function registerPromotionRoutes(app, deps) {
2884
+ const route = app.withTypeProvider();
2885
+ const promotionDeps = { deploy: deps.deploy, defaultBranch: deps.defaultBranch, now: deps.now, log: deps.log };
2886
+ route.route({
2887
+ method: "GET",
2888
+ url: "/api/environments",
2889
+ schema: { response: { 200: EnvironmentsStatusSchema } },
2890
+ handler: async (request, reply) => {
2891
+ hostedUserOf(request);
2892
+ return reply.code(200).send(await environmentsStatus(promotionDeps));
2893
+ }
2894
+ });
2895
+ route.route({
2896
+ method: "POST",
2897
+ url: "/api/environments/:environment/promotions",
2898
+ schema: { params: EnvironmentParamsSchema, body: PromotionInputSchema, response: { 201: PromotionRecordSchema } },
2899
+ handler: async (request, reply) => {
2900
+ const user = hostedUserOf(request);
2901
+ const record = await promote(promotionDeps, user, { environment: request.params.environment, ...request.body });
2902
+ return reply.code(201).send(record);
2903
+ }
2904
+ });
2905
+ }
2906
+
2907
+ // src/server/hosted/usage.ts
2908
+ import { z as z10 } from "zod";
2909
+ var UsageEntrySchema = z10.object({
2910
+ at: z10.string(),
2911
+ /** brief → spec from the instance; `speculate` entries arrive with the generator's lot. */
2912
+ action: z10.enum(["brief", "speculate"]),
2913
+ attempt: z10.number().int().positive().optional(),
2914
+ model: z10.string(),
2915
+ inputTokens: z10.number().int().nonnegative(),
2916
+ outputTokens: z10.number().int().nonnegative(),
2917
+ actor: z10.string().optional(),
2918
+ specId: z10.string().optional()
2919
+ });
2920
+ var USAGE_MARKER = "<!-- ogygie-usage -->";
2921
+ var DEFAULT_MODEL_COSTS = {
2922
+ "claude-fable-5-1": { input: 10, output: 50 },
2923
+ "claude-opus-5-5": { input: 4, output: 20 },
2924
+ "claude-sonnet-5-5": { input: 2, output: 10 },
2925
+ "claude-haiku-5-5": { input: 0.1, output: 0.5 }
2926
+ };
2927
+ function estimateUsd(entry, costs = DEFAULT_MODEL_COSTS) {
2928
+ const cost = costs[entry.model];
2929
+ if (cost === void 0) return null;
2930
+ return (entry.inputTokens * cost.input + entry.outputTokens * cost.output) / 1e6;
2931
+ }
2932
+ function formatUsageComment(entries) {
2933
+ const rows = entries.map((entry) => `| ${entry.at.slice(0, 16).replace("T", " ")} | ${entry.action}${entry.attempt === void 0 ? "" : ` ${entry.attempt}`} | ${entry.model} | ${entry.inputTokens} | ${entry.outputTokens} |`);
2934
+ return [
2935
+ USAGE_MARKER,
2936
+ "**Consommation de mod\xE8les (journal du studio h\xE9berg\xE9)** \u2014 estimation, la facture vient de l'espace de travail.",
2937
+ "",
2938
+ "| quand (UTC) | action | mod\xE8le | entr\xE9e | sortie |",
2939
+ "|---|---|---|---|---|",
2940
+ ...rows,
2941
+ "",
2942
+ "```json",
2943
+ JSON.stringify(entries),
2944
+ "```",
2945
+ ""
2946
+ ].join("\n");
2947
+ }
2948
+ function parseUsageComment(body) {
2949
+ if (!body.includes(USAGE_MARKER)) return [];
2950
+ const block = /```json\n([\s\S]*?)\n```/.exec(body)?.[1];
2951
+ if (block === void 0) return [];
2952
+ try {
2953
+ const parsed = z10.array(UsageEntrySchema).safeParse(JSON.parse(block));
2954
+ return parsed.success ? parsed.data : [];
2955
+ } catch {
2956
+ return [];
2957
+ }
2958
+ }
2959
+ function monthBounds(month) {
2960
+ const [year, monthIndex] = month.split("-").map((part) => Number.parseInt(part, 10));
2961
+ const start = new Date(Date.UTC(year ?? 1970, (monthIndex ?? 1) - 1, 1));
2962
+ const end = new Date(Date.UTC(year ?? 1970, monthIndex ?? 1, 1));
2963
+ return { start: start.toISOString(), end: end.toISOString() };
2964
+ }
2965
+ async function consumptionReport(input) {
2966
+ const { start, end } = monthBounds(input.month);
2967
+ const costs = input.costs ?? DEFAULT_MODEL_COSTS;
2968
+ const journals = await input.source.journalsSince(start);
2969
+ const specs = [];
2970
+ for (const journal of journals) {
2971
+ const inMonth = journal.entries.filter((entry) => entry.at >= start && entry.at < end);
2972
+ if (inMonth.length === 0) continue;
2973
+ const uncosted = /* @__PURE__ */ new Set();
2974
+ let estimatedUsd = 0;
2975
+ for (const entry of inMonth) {
2976
+ const usd = estimateUsd(entry, costs);
2977
+ if (usd === null) uncosted.add(entry.model);
2978
+ else estimatedUsd += usd;
2979
+ }
2980
+ specs.push({
2981
+ specId: journal.specId,
2982
+ pullRequestNumber: journal.pullRequestNumber,
2983
+ runs: inMonth.length,
2984
+ inputTokens: inMonth.reduce((sum, entry) => sum + entry.inputTokens, 0),
2985
+ outputTokens: inMonth.reduce((sum, entry) => sum + entry.outputTokens, 0),
2986
+ estimatedUsd: Math.round(estimatedUsd * 1e4) / 1e4,
2987
+ uncostedModels: [...uncosted].sort()
2988
+ });
2989
+ }
2990
+ specs.sort((a, b) => b.estimatedUsd - a.estimatedUsd || a.specId.localeCompare(b.specId));
2991
+ const totals = {
2992
+ runs: specs.reduce((sum, spec) => sum + spec.runs, 0),
2993
+ inputTokens: specs.reduce((sum, spec) => sum + spec.inputTokens, 0),
2994
+ outputTokens: specs.reduce((sum, spec) => sum + spec.outputTokens, 0),
2995
+ estimatedUsd: Math.round(specs.reduce((sum, spec) => sum + spec.estimatedUsd, 0) * 1e4) / 1e4
2996
+ };
2997
+ const budgetRatio = input.budgetUsd === null ? null : totals.estimatedUsd / input.budgetUsd;
2998
+ return { month: input.month, specs, totals, budgetUsd: input.budgetUsd, budgetRatio, warning: budgetRatio !== null && budgetRatio >= 0.8 };
2999
+ }
3000
+
3001
+ // src/server/hosted/consumption-routes.ts
3002
+ async function registerConsumptionRoutes(app, deps) {
3003
+ const route = app.withTypeProvider();
3004
+ const cache = /* @__PURE__ */ new Map();
3005
+ const cacheMs = deps.cacheMs ?? 5 * 60 * 1e3;
3006
+ route.route({
3007
+ method: "GET",
3008
+ url: "/api/consumption",
3009
+ schema: { querystring: ConsumptionQuerySchema, response: { 200: ConsumptionReportSchema } },
3010
+ handler: async (request, reply) => {
3011
+ hostedUserOf(request);
3012
+ const month = request.query.month ?? new Date(deps.now()).toISOString().slice(0, 7);
3013
+ const cached = cache.get(month);
3014
+ if (cached !== void 0 && deps.now() - cached.at < cacheMs) return reply.code(200).send(cached.report);
3015
+ const report = await consumptionReport({
3016
+ source: deps.usage,
3017
+ month,
3018
+ budgetUsd: deps.config.model?.monthlyBudgetUsd ?? null,
3019
+ ...deps.config.model?.costPerMillionUsd === void 0 ? {} : { costs: deps.config.model.costPerMillionUsd }
3020
+ });
3021
+ cache.set(month, { at: deps.now(), report });
3022
+ return reply.code(200).send(report);
3023
+ }
3024
+ });
3025
+ }
3026
+
3027
+ // src/server/hosted/preview-routes.ts
3028
+ import { z as z11 } from "zod";
3029
+ var PreviewParamsSchema = SpecParamsSchema.extend({ state: z11.string().min(1).max(80) });
3030
+ var CompositionPreviewParamsSchema = PreviewParamsSchema.extend({ composition: z11.string().min(1).max(80) });
3031
+ var PREVIEW_CSP = "sandbox; default-src 'none'; style-src 'unsafe-inline'; img-src data:; font-src data:";
3032
+ async function registerPreviewRoutes(app, deps) {
3033
+ const route = app.withTypeProvider();
3034
+ route.route({
3035
+ method: "GET",
3036
+ url: "/api/specs/:specId/previews",
3037
+ schema: { params: SpecParamsSchema, response: { 200: PreviewsListSchema } },
3038
+ handler: async (request, reply) => {
3039
+ const user = hostedUserOf(request);
3040
+ const storage = deps.storageFor(user);
3041
+ const { specId } = request.params;
3042
+ const specPath = `specs/${specId}.spec.md`;
3043
+ const composition = await storage.exists(specPath) ? compositionOfSpec(await storage.readFile(specPath)) : null;
3044
+ const compositions = (await listPreviewCompositions(storage, specId)).map((entry) => ({ ...entry, selected: entry.name === composition }));
3045
+ const previews = composition !== null && compositions.some((entry) => entry.name === composition) ? await listPreviews(storage, specId, composition) : await listPreviews(storage, specId);
3046
+ return reply.code(200).send({ specId, previews, composition, compositions });
3047
+ }
3048
+ });
3049
+ route.route({
3050
+ method: "GET",
3051
+ url: "/api/specs/:specId/previews/:composition/:state",
3052
+ schema: { params: CompositionPreviewParamsSchema },
3053
+ handler: async (request, reply) => {
3054
+ const user = hostedUserOf(request);
3055
+ const html = await readPreview(deps.storageFor(user), request.params.specId, request.params.state, request.params.composition);
3056
+ return reply.code(200).header("content-type", "text/html; charset=utf-8").header("content-security-policy", PREVIEW_CSP).header("x-frame-options", "SAMEORIGIN").header("cache-control", "no-store").send(html);
3057
+ }
3058
+ });
3059
+ route.route({
3060
+ method: "GET",
3061
+ url: "/api/specs/:specId/previews/:state",
3062
+ schema: { params: PreviewParamsSchema },
3063
+ handler: async (request, reply) => {
3064
+ const user = hostedUserOf(request);
3065
+ const html = await readPreview(deps.storageFor(user), request.params.specId, request.params.state);
3066
+ return reply.code(200).header("content-type", "text/html; charset=utf-8").header("content-security-policy", PREVIEW_CSP).header("x-frame-options", "SAMEORIGIN").header("cache-control", "no-store").send(html);
3067
+ }
3068
+ });
3069
+ }
3070
+
3071
+ // src/server/hosted/composition-routes.ts
3072
+ import { z as z12 } from "zod";
3073
+
3074
+ // src/server/hosted/compositions.ts
3075
+ var COMPOSITION_NAME = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
3076
+ async function selectComposition(deps, specId, composition) {
3077
+ if (!COMPOSITION_NAME.test(composition)) {
3078
+ throwOgygieError(
3079
+ { code: "COMPOSITION_NOT_FOUND", message: `"${composition}" is not a composition name.`, file: null, cause: "a composition is named in kebab-case, as its `# Design` line declares it", suggestedFix: "Pick a composition from GET /api/specs/<id>/previews." },
3080
+ 404
3081
+ );
3082
+ }
3083
+ const path = `specs/${specId}.spec.md`;
3084
+ const head = await deps.storage.exists(path) ? await deps.storage.readFile(path) : null;
3085
+ if (head !== null && compositionOfSpec(head) === composition) {
3086
+ return { specId, composition, alreadySelected: true };
3087
+ }
3088
+ const commits = await deps.pulls.listFileCommits(specBranchOf(specId), path);
3089
+ for (const commit of commits) {
3090
+ let content;
3091
+ try {
3092
+ content = await deps.readerFor(commit.sha).readFile(path);
3093
+ } catch {
3094
+ continue;
3095
+ }
3096
+ if (compositionOfSpec(content) !== composition) continue;
3097
+ await deps.storage.write(path, content, `spec: select composition ${composition} for ${specId}
3098
+
3099
+ La composition \xAB ${composition} \xBB est retenue parmi celles propos\xE9es ; les autres restent dans l'historique de la branche, jamais r\xE9\xE9crit.`);
3100
+ deps.log(JSON.stringify({ action: "select-composition", specId, composition, fromSha: commit.sha }));
3101
+ return { specId, composition, alreadySelected: false };
3102
+ }
3103
+ return throwOgygieError(
3104
+ {
3105
+ code: "COMPOSITION_NOT_FOUND",
3106
+ message: `No version of spec "${specId}" on its branch declares the composition "${composition}".`,
3107
+ file: path,
3108
+ cause: `${commits.length} commit(s) of ${path} read on ${specBranchOf(specId)}, none with \`Composition : ${composition}\` under # Design`,
3109
+ suggestedFix: "Choose one of the compositions the previews list names; a composition the model never proposed has no spec to select."
3110
+ },
3111
+ 404
3112
+ );
3113
+ }
3114
+
3115
+ // src/server/hosted/composition-routes.ts
3116
+ var CompositionParamsSchema = SpecParamsSchema.extend({ composition: z12.string().min(1).max(80) });
3117
+ async function registerCompositionRoutes(app, deps) {
3118
+ const route = app.withTypeProvider();
3119
+ route.route({
3120
+ method: "POST",
3121
+ url: "/api/specs/:specId/compositions/:composition/select",
3122
+ schema: { params: CompositionParamsSchema, response: { 200: SelectCompositionOutputSchema } },
3123
+ handler: async (request, reply) => {
3124
+ const user = hostedUserOf(request);
3125
+ requireRole(user, "author", `select a composition of ${request.params.specId}`);
3126
+ const result = await selectComposition(
3127
+ { storage: deps.storageFor(user), pulls: deps.pulls, readerFor: deps.readerFor, log: deps.log },
3128
+ request.params.specId,
3129
+ request.params.composition
3130
+ );
3131
+ return reply.code(200).send(result);
3132
+ }
3133
+ });
3134
+ }
3135
+
3136
+ // src/server/app.ts
3137
+ async function createStudioApp(options) {
3138
+ const app = Fastify({ logger: false });
3139
+ app.setValidatorCompiler(validatorCompiler);
3140
+ app.setSerializerCompiler(serializerCompiler);
3141
+ app.setErrorHandler((error, _request, reply) => {
3142
+ if (error instanceof OgygieFailure) {
3143
+ return reply.status(error.httpStatus).send(error.error);
3144
+ }
3145
+ if (hasZodFastifySchemaValidationErrors(error)) {
3146
+ return reply.status(400).send(
3147
+ ogygieError({
3148
+ code: "CONTRACT_VALIDATION_FAILED",
3149
+ message: "The request does not satisfy the route's input contract.",
3150
+ file: null,
3151
+ cause: error.validation.map((issue) => `${issue.instancePath || "(root)"}: ${issue.message ?? "invalid"}`).join(" ; "),
1069
3152
  suggestedFix: "Fix the listed fields to match the route's Zod input schema."
1070
3153
  })
1071
3154
  );
@@ -1081,7 +3164,31 @@ async function createStudioApp(options) {
1081
3164
  })
1082
3165
  );
1083
3166
  });
1084
- await registerStudioRoutes(app, options);
3167
+ if (options.hosted !== void 0) {
3168
+ const hosted = options.hosted;
3169
+ await registerHostedRoutes(app, hosted);
3170
+ await registerStudioRoutes(app, (request) => ({ storage: hosted.storageFor(hostedUserOf(request)) }));
3171
+ await registerPreviewRoutes(app, { storageFor: hosted.storageFor });
3172
+ if (hosted.writer !== void 0) {
3173
+ await registerBriefRoutes(app, { writer: hosted.writer, storageFor: hosted.storageFor, log: hosted.log, usage: hosted.usage, now: hosted.now });
3174
+ }
3175
+ if (hosted.pulls !== void 0) {
3176
+ await registerMergeRoutes(app, { pulls: hosted.pulls, storageFor: hosted.storageFor, config: hosted.config, now: hosted.now, log: hosted.log });
3177
+ if (hosted.readerFor !== void 0) {
3178
+ await registerCompositionRoutes(app, { pulls: hosted.pulls, readerFor: hosted.readerFor, storageFor: hosted.storageFor, log: hosted.log });
3179
+ }
3180
+ }
3181
+ if (hosted.usage !== void 0) {
3182
+ await registerConsumptionRoutes(app, { usage: hosted.usage, config: hosted.config, now: hosted.now });
3183
+ }
3184
+ if (hosted.deploy !== void 0) {
3185
+ await registerPromotionRoutes(app, { deploy: hosted.deploy, defaultBranch: hosted.config.repo.defaultBranch, now: hosted.now, log: hosted.log });
3186
+ }
3187
+ } else if (options.storage !== void 0) {
3188
+ await registerStudioRoutes(app, { storage: options.storage });
3189
+ } else {
3190
+ throw new Error("createStudioApp: either `storage` (local) or `hosted` must be given.");
3191
+ }
1085
3192
  if (options.uiDistDir !== void 0) {
1086
3193
  const fastifyStatic = (await import("@fastify/static")).default;
1087
3194
  await app.register(fastifyStatic, { root: options.uiDistDir });
@@ -1104,6 +3211,518 @@ async function createStudioApp(options) {
1104
3211
  return app;
1105
3212
  }
1106
3213
 
3214
+ // src/server/hosted/github-app.ts
3215
+ import { createSign } from "node:crypto";
3216
+ var base64url2 = (input) => Buffer.from(input).toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
3217
+ function signGithubAppJwt(input) {
3218
+ const nowSeconds = Math.floor(input.nowMs / 1e3);
3219
+ const header = base64url2(JSON.stringify({ alg: "RS256", typ: "JWT" }));
3220
+ const payload = base64url2(
3221
+ JSON.stringify({ iat: nowSeconds - 60, exp: nowSeconds + 600, iss: input.appId })
3222
+ );
3223
+ const signer = createSign("RSA-SHA256");
3224
+ signer.update(`${header}.${payload}`);
3225
+ const signature = base64url2(signer.sign(input.privateKeyPem));
3226
+ return `${header}.${payload}.${signature}`;
3227
+ }
3228
+ var RENEW_BEFORE_MS = 5 * 60 * 1e3;
3229
+ function createGithubAppAuth(deps) {
3230
+ const apiBase = deps.apiBase ?? "https://api.github.com";
3231
+ let cached = null;
3232
+ async function mint() {
3233
+ const jwt = signGithubAppJwt({ appId: deps.appId, privateKeyPem: deps.privateKeyPem, nowMs: deps.now() });
3234
+ let response;
3235
+ try {
3236
+ response = await deps.fetchImpl(`${apiBase}/app/installations/${deps.installationId}/access_tokens`, {
3237
+ method: "POST",
3238
+ headers: {
3239
+ authorization: `Bearer ${jwt}`,
3240
+ accept: "application/vnd.github+json",
3241
+ "x-github-api-version": "2022-11-28",
3242
+ "user-agent": "ogygie-studio"
3243
+ }
3244
+ });
3245
+ } catch (thrown) {
3246
+ return throwOgygieError({
3247
+ code: "GITHUB_APP_AUTH_FAILED",
3248
+ message: "GitHub could not be reached to mint an installation token.",
3249
+ file: null,
3250
+ cause: thrown instanceof Error ? thrown.message : String(thrown),
3251
+ suggestedFix: "Retry once the network is back; nothing was written."
3252
+ });
3253
+ }
3254
+ if (!response.ok) {
3255
+ return throwOgygieError({
3256
+ code: "GITHUB_APP_AUTH_FAILED",
3257
+ message: `GitHub answered ${response.status} when minting the installation token.`,
3258
+ file: null,
3259
+ cause: `status=${response.status} for installation ${deps.installationId}`,
3260
+ suggestedFix: "Check githubApp.appId, githubApp.installationId and the private key: the app must be installed on this repo with contents and pull requests permissions."
3261
+ });
3262
+ }
3263
+ const body = await response.json();
3264
+ return { token: body.token, expiresAtMs: Date.parse(body.expires_at) };
3265
+ }
3266
+ async function installationToken() {
3267
+ if (cached === null || cached.expiresAtMs - deps.now() < RENEW_BEFORE_MS) {
3268
+ cached = await mint();
3269
+ }
3270
+ return cached.token;
3271
+ }
3272
+ const installationFetch = async (input, init) => {
3273
+ const token = await installationToken();
3274
+ const headers = new Headers(init?.headers);
3275
+ headers.set("authorization", `Bearer ${token}`);
3276
+ return deps.fetchImpl(input, { ...init, headers });
3277
+ };
3278
+ return { installationToken, installationFetch };
3279
+ }
3280
+
3281
+ // src/server/hosted/mail.ts
3282
+ function createHttpMailSender(deps) {
3283
+ return {
3284
+ async send(input) {
3285
+ let response;
3286
+ try {
3287
+ response = await deps.fetchImpl(deps.providerUrl, {
3288
+ method: "POST",
3289
+ headers: { authorization: `Bearer ${deps.apiKey}`, "content-type": "application/json" },
3290
+ body: JSON.stringify({ from: deps.fromAddress, to: input.to, subject: input.subject, text: input.text })
3291
+ });
3292
+ } catch (thrown) {
3293
+ return throwOgygieError({
3294
+ code: "MAIL_PROVIDER_UNAVAILABLE",
3295
+ message: "The mail provider could not be reached.",
3296
+ file: null,
3297
+ cause: thrown instanceof Error ? thrown.message : String(thrown),
3298
+ suggestedFix: "Check mail.providerUrl and the network; the sign-in link was not sent."
3299
+ });
3300
+ }
3301
+ if (!response.ok) {
3302
+ return throwOgygieError({
3303
+ code: "MAIL_PROVIDER_UNAVAILABLE",
3304
+ message: `The mail provider answered ${response.status}.`,
3305
+ file: null,
3306
+ cause: `status=${response.status}`,
3307
+ suggestedFix: "Check the provider key (mail.apiKeyPath) and the sender address (mail.fromAddress)."
3308
+ });
3309
+ }
3310
+ }
3311
+ };
3312
+ }
3313
+
3314
+ // ../ogygie-core/src/model.ts
3315
+ function createMessagesClient(deps) {
3316
+ const apiBase = deps.apiBase ?? "https://api.anthropic.com";
3317
+ const credential = deps.credentialHint ?? "the model API key";
3318
+ return {
3319
+ async complete(input) {
3320
+ let response;
3321
+ try {
3322
+ response = await deps.fetchImpl(`${apiBase}/v1/messages`, {
3323
+ method: "POST",
3324
+ headers: {
3325
+ "x-api-key": deps.apiKey,
3326
+ "anthropic-version": "2023-06-01",
3327
+ "content-type": "application/json"
3328
+ },
3329
+ body: JSON.stringify({
3330
+ model: input.model,
3331
+ max_tokens: input.maxTokens ?? 16e3,
3332
+ system: [{ type: "text", text: input.system, cache_control: { type: "ephemeral" } }],
3333
+ messages: input.messages,
3334
+ output_config: { effort: input.effort ?? "high" }
3335
+ })
3336
+ });
3337
+ } catch (thrown) {
3338
+ return throwOgygieError(
3339
+ {
3340
+ code: "MODEL_UNAVAILABLE",
3341
+ message: "The model API could not be reached.",
3342
+ file: null,
3343
+ cause: thrown instanceof Error ? thrown.message : String(thrown),
3344
+ suggestedFix: "Retry once the network is back; nothing was written."
3345
+ },
3346
+ 502
3347
+ );
3348
+ }
3349
+ if (!response.ok) {
3350
+ const body = await response.text();
3351
+ const budget = response.status === 400 && /credit|billing|spend|budget/i.test(body);
3352
+ return throwOgygieError(
3353
+ {
3354
+ code: budget ? "MODEL_BUDGET_EXCEEDED" : "MODEL_UNAVAILABLE",
3355
+ message: budget ? "The model workspace refused the call: its spending limit is reached." : `The model API answered ${response.status}.`,
3356
+ file: null,
3357
+ cause: `status=${response.status} ${body.slice(0, 300)}`,
3358
+ suggestedFix: budget ? "The monthly budget of this workspace is spent; it resets with the month, or the operator raises it." : `Check ${credential} and retry; nothing was written.`
3359
+ },
3360
+ budget ? 429 : 502
3361
+ );
3362
+ }
3363
+ const answer = await response.json();
3364
+ return {
3365
+ text: answer.content.filter((block) => block.type === "text" && typeof block.text === "string").map((block) => block.text ?? "").join(""),
3366
+ model: answer.model,
3367
+ stopReason: answer.stop_reason,
3368
+ usage: { inputTokens: answer.usage.input_tokens, outputTokens: answer.usage.output_tokens }
3369
+ };
3370
+ }
3371
+ };
3372
+ }
3373
+
3374
+ // src/server/hosted/model.ts
3375
+ function createAnthropicSpecWriter(deps) {
3376
+ const client = createMessagesClient({
3377
+ apiKey: deps.apiKey,
3378
+ fetchImpl: deps.fetchImpl,
3379
+ ...deps.apiBase === void 0 ? {} : { apiBase: deps.apiBase },
3380
+ credentialHint: "OGYGIE_MODEL_API_KEY"
3381
+ });
3382
+ return {
3383
+ async write(input) {
3384
+ const messages = [{ role: "user", content: input.user }];
3385
+ if (input.previous !== void 0) {
3386
+ messages.push({ role: "assistant", content: input.previous.draft });
3387
+ messages.push({
3388
+ role: "user",
3389
+ content: `Ce brouillon est refus\xE9 par les gates du repo pour les raisons suivantes :
3390
+ - ${input.previous.problems.join("\n- ")}
3391
+
3392
+ R\xE9\xE9cris la spec COMPL\xC8TE, corrig\xE9e, sans autre commentaire.`
3393
+ });
3394
+ }
3395
+ const answer = await client.complete({ model: deps.model, system: input.system, messages, maxTokens: deps.maxTokens ?? 16e3, effort: "high" });
3396
+ if (answer.stopReason === "refusal") {
3397
+ return throwOgygieError(
3398
+ {
3399
+ code: "MODEL_REFUSED",
3400
+ message: "The model declined to write this brief.",
3401
+ file: null,
3402
+ cause: "stop_reason=refusal",
3403
+ suggestedFix: "Rephrase the brief as a product need; the studio writes specs, nothing else."
3404
+ },
3405
+ 422
3406
+ );
3407
+ }
3408
+ if (answer.stopReason === "max_tokens") {
3409
+ return throwOgygieError(
3410
+ {
3411
+ code: "MODEL_OUTPUT_TRUNCATED",
3412
+ message: "The draft was cut before its end.",
3413
+ file: null,
3414
+ cause: `stop_reason=max_tokens at ${deps.maxTokens ?? 16e3} tokens`,
3415
+ suggestedFix: "Split the brief: one rubric per brief, one spec per rubric."
3416
+ },
3417
+ 422
3418
+ );
3419
+ }
3420
+ return { text: answer.text, model: answer.model, usage: answer.usage };
3421
+ }
3422
+ };
3423
+ }
3424
+
3425
+ // src/server/hosted/github-pulls.ts
3426
+ var RED_CONCLUSIONS = /* @__PURE__ */ new Set(["failure", "cancelled", "timed_out", "action_required", "startup_failure"]);
3427
+ function checksStateOf(runs, statuses) {
3428
+ if (runs.length === 0 && statuses.length === 0) return "none";
3429
+ const red = runs.some((run) => run.conclusion !== null && RED_CONCLUSIONS.has(run.conclusion)) || statuses.some((status) => status.state === "failure" || status.state === "error");
3430
+ if (red) return "red";
3431
+ const pending = runs.some((run) => run.status !== "completed") || statuses.some((status) => status.state === "pending");
3432
+ return pending ? "pending" : "green";
3433
+ }
3434
+ function createGithubPullsClient(deps) {
3435
+ const apiBase = deps.apiBase ?? "https://api.github.com";
3436
+ const repoUrl = `${apiBase}/repos/${deps.github.owner}/${deps.github.repo}`;
3437
+ async function request(method, url, body, allow = []) {
3438
+ let response;
3439
+ try {
3440
+ response = await deps.fetchImpl(url, {
3441
+ method,
3442
+ headers: {
3443
+ accept: "application/vnd.github+json",
3444
+ "x-github-api-version": "2022-11-28",
3445
+ "user-agent": "ogygie-studio",
3446
+ ...body === void 0 ? {} : { "content-type": "application/json" }
3447
+ },
3448
+ ...body === void 0 ? {} : { body: JSON.stringify(body) }
3449
+ });
3450
+ } catch (thrown) {
3451
+ return throwOgygieError({
3452
+ code: "GITHUB_UNAVAILABLE",
3453
+ message: `GitHub could not be reached for ${deps.github.owner}/${deps.github.repo}.`,
3454
+ file: null,
3455
+ cause: thrown instanceof Error ? thrown.message : String(thrown),
3456
+ suggestedFix: "Retry once the network is back; nothing was merged."
3457
+ });
3458
+ }
3459
+ if (allow.includes(response.status)) return { status: response.status, json: null };
3460
+ if (!response.ok) {
3461
+ return throwOgygieError({
3462
+ code: "GITHUB_UNAVAILABLE",
3463
+ message: `GitHub answered ${response.status} for ${method} ${url.replace(apiBase, "")}.`,
3464
+ file: null,
3465
+ cause: `status=${response.status}`,
3466
+ suggestedFix: "Retry later; check the app installation grants contents and pull requests on this repo."
3467
+ });
3468
+ }
3469
+ const text = await response.text();
3470
+ return { status: response.status, json: text === "" ? null : JSON.parse(text) };
3471
+ }
3472
+ return {
3473
+ async findOpenPullRequest(branch) {
3474
+ const { json } = await request(
3475
+ "GET",
3476
+ `${repoUrl}/pulls?state=open&head=${encodeURIComponent(`${deps.github.owner}:${branch}`)}`,
3477
+ void 0
3478
+ );
3479
+ const first = json?.[0];
3480
+ return first === void 0 ? null : { number: first.number, url: first.html_url, headSha: first.head.sha, title: first.title, labels: (first.labels ?? []).map((label) => label.name) };
3481
+ },
3482
+ async listOpenSpecPullRequests() {
3483
+ const { json } = await request(
3484
+ "GET",
3485
+ `${repoUrl}/pulls?state=open&sort=updated&direction=desc&per_page=100`,
3486
+ void 0
3487
+ );
3488
+ return (json ?? []).filter((pull) => pull.head.ref.startsWith("spec/")).map((pull) => ({ number: pull.number, url: pull.html_url, headSha: pull.head.sha, title: pull.title, branch: pull.head.ref, labels: (pull.labels ?? []).map((label) => label.name) }));
3489
+ },
3490
+ async listFileCommits(branch, path) {
3491
+ const { json } = await request(
3492
+ "GET",
3493
+ `${repoUrl}/commits?sha=${encodeURIComponent(branch)}&path=${encodeURIComponent(path)}&per_page=50`,
3494
+ void 0
3495
+ );
3496
+ return (json ?? []).map((entry) => ({ sha: entry.sha, message: entry.commit.message }));
3497
+ },
3498
+ async getPullRequest(number) {
3499
+ const { json } = await request(
3500
+ "GET",
3501
+ `${repoUrl}/pulls/${number}`,
3502
+ void 0
3503
+ );
3504
+ if (json === null) throw new Error("empty pull request answer");
3505
+ return { number: json.number, url: json.html_url, headSha: json.head.sha, mergeable: json.mergeable, merged: json.merged };
3506
+ },
3507
+ async getChecks(sha) {
3508
+ const [runsAnswer, statusAnswer] = await Promise.all([
3509
+ request("GET", `${repoUrl}/commits/${sha}/check-runs?per_page=100`, void 0),
3510
+ request("GET", `${repoUrl}/commits/${sha}/status`, void 0)
3511
+ ]);
3512
+ const runs = runsAnswer.json?.check_runs ?? [];
3513
+ const statuses = statusAnswer.json?.statuses ?? [];
3514
+ return {
3515
+ state: checksStateOf(runs, statuses),
3516
+ runs: [
3517
+ ...runs.map((run) => ({ name: run.name, conclusion: run.status === "completed" ? run.conclusion : "pending" })),
3518
+ ...statuses.map((status) => ({ name: status.context, conclusion: status.state }))
3519
+ ]
3520
+ };
3521
+ },
3522
+ async mergePullRequest(number, input) {
3523
+ const { status, json } = await request(
3524
+ "PUT",
3525
+ `${repoUrl}/pulls/${number}/merge`,
3526
+ { merge_method: "squash", commit_title: input.title, commit_message: input.message },
3527
+ [405, 409]
3528
+ );
3529
+ if (status === 405 || status === 409) {
3530
+ return throwOgygieError(
3531
+ {
3532
+ code: "PULL_REQUEST_CONFLICTING",
3533
+ message: `GitHub refused to merge pull request #${number} (${status}).`,
3534
+ file: null,
3535
+ cause: status === 409 ? "the head moved since it was read" : "not mergeable: conflict, or a required check missing",
3536
+ suggestedFix: "Regenerate the branch on the current default branch (operator), then merge again."
3537
+ },
3538
+ 409
3539
+ );
3540
+ }
3541
+ return { sha: json?.sha ?? "" };
3542
+ },
3543
+ async deleteBranch(branch) {
3544
+ await request("DELETE", `${repoUrl}/git/refs/heads/${encodeURIComponent(branch)}`, void 0, [404, 422]);
3545
+ }
3546
+ };
3547
+ }
3548
+
3549
+ // src/server/hosted/github-deploy.ts
3550
+ function createGithubDeployClient(deps) {
3551
+ const apiBase = deps.apiBase ?? "https://api.github.com";
3552
+ const repoUrl = `${apiBase}/repos/${deps.github.owner}/${deps.github.repo}`;
3553
+ async function request(method, url, body, allowNotFound = false) {
3554
+ let response;
3555
+ try {
3556
+ response = await deps.fetchImpl(url, {
3557
+ method,
3558
+ headers: {
3559
+ accept: "application/vnd.github+json",
3560
+ "x-github-api-version": "2022-11-28",
3561
+ "user-agent": "ogygie-studio",
3562
+ ...body === void 0 ? {} : { "content-type": "application/json" }
3563
+ },
3564
+ ...body === void 0 ? {} : { body: JSON.stringify(body) }
3565
+ });
3566
+ } catch (thrown) {
3567
+ return throwOgygieError({
3568
+ code: "GITHUB_UNAVAILABLE",
3569
+ message: `GitHub could not be reached for ${deps.github.owner}/${deps.github.repo}.`,
3570
+ file: null,
3571
+ cause: thrown instanceof Error ? thrown.message : String(thrown),
3572
+ suggestedFix: "Retry once the network is back; no ref was moved."
3573
+ });
3574
+ }
3575
+ if (response.status === 404 && allowNotFound) return null;
3576
+ if (!response.ok) {
3577
+ return throwOgygieError({
3578
+ code: "GITHUB_UNAVAILABLE",
3579
+ message: `GitHub answered ${response.status} for ${method} ${url.replace(apiBase, "")}.`,
3580
+ file: null,
3581
+ cause: `status=${response.status}`,
3582
+ suggestedFix: response.status === 422 ? "Check the ref and the sha: a protected ref only moves by the installation, in fast-forward unless forced." : "Retry later; check the app installation grants contents and deployments on this repo."
3583
+ });
3584
+ }
3585
+ const text = await response.text();
3586
+ return text === "" ? null : JSON.parse(text);
3587
+ }
3588
+ return {
3589
+ async getRefSha(ref) {
3590
+ const answer = await request("GET", `${repoUrl}/git/ref/heads/${encodeURIComponent(ref)}`, void 0, true);
3591
+ return answer?.object.sha ?? null;
3592
+ },
3593
+ async getCommitSha(shaOrRef) {
3594
+ const answer = await request("GET", `${repoUrl}/commits/${encodeURIComponent(shaOrRef)}`, void 0, true);
3595
+ return answer?.sha ?? null;
3596
+ },
3597
+ async compare(base, head) {
3598
+ const answer = await request(
3599
+ "GET",
3600
+ `${repoUrl}/compare/${encodeURIComponent(base)}...${encodeURIComponent(head)}?per_page=250`,
3601
+ void 0
3602
+ );
3603
+ return {
3604
+ status: answer?.status ?? "diverged",
3605
+ commits: (answer?.commits ?? []).map((entry) => ({ sha: entry.sha, message: entry.commit.message, authoredAt: entry.commit.author.date }))
3606
+ };
3607
+ },
3608
+ async updateRef(ref, sha, force) {
3609
+ await request("PATCH", `${repoUrl}/git/refs/heads/${encodeURIComponent(ref)}`, { sha, force });
3610
+ },
3611
+ async createDeployment(input) {
3612
+ const answer = await request("POST", `${repoUrl}/deployments`, {
3613
+ ref: input.sha,
3614
+ environment: input.environment,
3615
+ description: input.description,
3616
+ payload: input.payload,
3617
+ auto_merge: false,
3618
+ required_contexts: [],
3619
+ production_environment: input.environment === "production"
3620
+ });
3621
+ return { id: answer?.id ?? 0 };
3622
+ },
3623
+ async latestDeployment(environment) {
3624
+ const deployments = await request(
3625
+ "GET",
3626
+ `${repoUrl}/deployments?environment=${encodeURIComponent(environment)}&per_page=1`,
3627
+ void 0
3628
+ );
3629
+ const latest = deployments?.[0];
3630
+ if (latest === void 0) return null;
3631
+ const statuses = await request("GET", `${repoUrl}/deployments/${latest.id}/statuses?per_page=1`, void 0);
3632
+ return { id: latest.id, sha: latest.sha, createdAt: latest.created_at, state: statuses?.[0]?.state ?? null };
3633
+ },
3634
+ async listTags(prefix) {
3635
+ const refs = await request("GET", `${repoUrl}/git/matching-refs/tags/${prefix}`, void 0);
3636
+ return (refs ?? []).map((entry) => entry.ref.replace(/^refs\/tags\//, ""));
3637
+ },
3638
+ async createTag(input) {
3639
+ const tag = await request("POST", `${repoUrl}/git/tags`, {
3640
+ tag: input.name,
3641
+ message: input.message,
3642
+ object: input.sha,
3643
+ type: "commit",
3644
+ tagger: { ...input.tagger, date: (/* @__PURE__ */ new Date()).toISOString() }
3645
+ });
3646
+ await request("POST", `${repoUrl}/git/refs`, { ref: `refs/tags/${input.name}`, sha: tag?.sha ?? input.sha });
3647
+ },
3648
+ async getPullRequestBody(number) {
3649
+ const pull = await request("GET", `${repoUrl}/pulls/${number}`, void 0, true);
3650
+ return pull?.body ?? "";
3651
+ }
3652
+ };
3653
+ }
3654
+
3655
+ // src/server/hosted/github-usage.ts
3656
+ function createGithubUsageClient(deps) {
3657
+ const apiBase = deps.apiBase ?? "https://api.github.com";
3658
+ const repoUrl = `${apiBase}/repos/${deps.github.owner}/${deps.github.repo}`;
3659
+ async function request(method, url, body) {
3660
+ let response;
3661
+ try {
3662
+ response = await deps.fetchImpl(url, {
3663
+ method,
3664
+ headers: {
3665
+ accept: "application/vnd.github+json",
3666
+ "x-github-api-version": "2022-11-28",
3667
+ "user-agent": "ogygie-studio",
3668
+ ...body === void 0 ? {} : { "content-type": "application/json" }
3669
+ },
3670
+ ...body === void 0 ? {} : { body: JSON.stringify(body) }
3671
+ });
3672
+ } catch (thrown) {
3673
+ return throwOgygieError({
3674
+ code: "GITHUB_UNAVAILABLE",
3675
+ message: `GitHub could not be reached for ${deps.github.owner}/${deps.github.repo}.`,
3676
+ file: null,
3677
+ cause: thrown instanceof Error ? thrown.message : String(thrown),
3678
+ suggestedFix: "Retry once the network is back."
3679
+ });
3680
+ }
3681
+ if (!response.ok) {
3682
+ return throwOgygieError({
3683
+ code: "GITHUB_UNAVAILABLE",
3684
+ message: `GitHub answered ${response.status} for ${method} ${url.replace(apiBase, "")}.`,
3685
+ file: null,
3686
+ cause: `status=${response.status}`,
3687
+ suggestedFix: "Retry later; check the app installation grants pull requests (write) on this repo."
3688
+ });
3689
+ }
3690
+ const text = await response.text();
3691
+ return text === "" ? null : JSON.parse(text);
3692
+ }
3693
+ async function journalComment(pullRequestNumber) {
3694
+ const comments = await request("GET", `${repoUrl}/issues/${pullRequestNumber}/comments?per_page=100`) ?? [];
3695
+ const found = comments.find((comment) => comment.body.includes(USAGE_MARKER));
3696
+ return found === void 0 ? null : { id: found.id, entries: parseUsageComment(found.body) };
3697
+ }
3698
+ return {
3699
+ async appendUsage(pullRequestNumber, entries) {
3700
+ const existing = await journalComment(pullRequestNumber);
3701
+ const body = formatUsageComment([...existing?.entries ?? [], ...entries]);
3702
+ if (existing === null) {
3703
+ await request("POST", `${repoUrl}/issues/${pullRequestNumber}/comments`, { body });
3704
+ } else {
3705
+ await request("PATCH", `${repoUrl}/issues/comments/${existing.id}`, { body });
3706
+ }
3707
+ },
3708
+ async journalsSince(sinceIso) {
3709
+ const pulls = await request(
3710
+ "GET",
3711
+ `${repoUrl}/pulls?state=all&sort=updated&direction=desc&per_page=100`
3712
+ ) ?? [];
3713
+ const journals = [];
3714
+ for (const pull of pulls) {
3715
+ if (pull.updated_at < sinceIso) break;
3716
+ if (!pull.head.ref.startsWith("spec/")) continue;
3717
+ const comment = await journalComment(pull.number);
3718
+ if (comment === null || comment.entries.length === 0) continue;
3719
+ journals.push({ specId: pull.head.ref.slice("spec/".length).replace(/-[0-9a-f]{7}$/, ""), pullRequestNumber: pull.number, entries: comment.entries });
3720
+ }
3721
+ return journals;
3722
+ }
3723
+ };
3724
+ }
3725
+
1107
3726
  // src/server/start.ts
1108
3727
  function studioPackageRoot() {
1109
3728
  let directory = dirname2(fileURLToPath(import.meta.url));
@@ -1159,7 +3778,142 @@ async function startStudio(options) {
1159
3778
  if (options.openBrowser !== false) openInBrowser(url);
1160
3779
  return app;
1161
3780
  }
3781
+ var MIN_SESSION_SECRET_LENGTH = 32;
3782
+ function readSecret(path, what, minLength = 1) {
3783
+ let value = "";
3784
+ try {
3785
+ value = readFileSync(resolvePath(path), "utf8").trim();
3786
+ } catch {
3787
+ value = "";
3788
+ }
3789
+ if (value.length < minLength) {
3790
+ throwOgygieError({
3791
+ code: "HOST_SECRET_MISSING",
3792
+ message: `The hosted studio cannot start: ${what} is missing or too short.`,
3793
+ file: path,
3794
+ cause: value.length === 0 ? "file absent or empty" : `${value.length} characters, minimum ${minLength}`,
3795
+ suggestedFix: `Write ${what} to that path (chmod 600) \u2014 a half-configured hosted mode is an error, not a degraded mode.`
3796
+ });
3797
+ }
3798
+ return value;
3799
+ }
3800
+ function resolvePath(path) {
3801
+ return path.startsWith("~/") ? join2(process.env["HOME"] ?? "", path.slice(2)) : path;
3802
+ }
3803
+ function loadHostConfig(configPath) {
3804
+ let raw;
3805
+ try {
3806
+ raw = JSON.parse(readFileSync(configPath, "utf8"));
3807
+ } catch (thrown) {
3808
+ return throwOgygieError({
3809
+ code: "HOST_CONFIG_INVALID",
3810
+ message: "The hosted studio configuration cannot be read as JSON.",
3811
+ file: configPath,
3812
+ cause: thrown instanceof Error ? thrown.message : String(thrown),
3813
+ suggestedFix: "Point --config at a readable ogygie-host.config.json."
3814
+ });
3815
+ }
3816
+ return parseHostConfig(raw, configPath);
3817
+ }
3818
+ async function startHostedStudio(options) {
3819
+ const config = loadHostConfig(options.configPath);
3820
+ const privateKeyPem = readSecret(config.githubApp.privateKeyPath, "the GitHub App private key");
3821
+ const sessionSecret = readSecret(config.session.secretPath, "the session secret", MIN_SESSION_SECRET_LENGTH);
3822
+ const mailApiKey = readSecret(config.mail.apiKeyPath, "the mail provider key");
3823
+ const modelApiKey = config.model === void 0 ? void 0 : process.env["OGYGIE_MODEL_API_KEY"] ?? "";
3824
+ if (modelApiKey !== void 0 && modelApiKey.length === 0) {
3825
+ throwOgygieError({
3826
+ code: "HOST_SECRET_MISSING",
3827
+ message: "The hosted studio cannot start: the configuration declares a model block but OGYGIE_MODEL_API_KEY is absent.",
3828
+ file: options.configPath,
3829
+ cause: "model block present, environment variable empty",
3830
+ suggestedFix: "Export OGYGIE_MODEL_API_KEY (the client's model workspace key, D50) in the instance environment, or remove the model block to run without briefs."
3831
+ });
3832
+ }
3833
+ const uiDistDir = studioUiDir();
3834
+ await buildUiIfMissing(uiDistDir);
3835
+ const now = () => Date.now();
3836
+ const log = (line) => {
3837
+ process.stderr.write(`${line}
3838
+ `);
3839
+ };
3840
+ const githubApp = createGithubAppAuth({
3841
+ appId: config.githubApp.appId,
3842
+ installationId: config.githubApp.installationId,
3843
+ privateKeyPem,
3844
+ fetchImpl: fetch,
3845
+ now
3846
+ });
3847
+ const repoId = config.repo.repo;
3848
+ const recentProposals = /* @__PURE__ */ new Map();
3849
+ const listSpecBranches = createGithubSpecBranchLister({ github: config.repo, fetchImpl: githubApp.installationFetch });
3850
+ const readerFor = (ref) => createGithubApiRepoAdapter({
3851
+ owner: config.repo.owner,
3852
+ repo: config.repo.repo,
3853
+ ref,
3854
+ fetchImpl: githubApp.installationFetch,
3855
+ userAgent: "ogygie-studio"
3856
+ });
3857
+ const client = createGithubWriteClient(
3858
+ { token: "installation", fetchImpl: githubApp.installationFetch, userAgent: "ogygie-studio", credentialHint: "the GitHub App installation" },
3859
+ config.repo
3860
+ );
3861
+ const deploy = config.environments === void 0 ? void 0 : createGithubDeployClient({ github: config.repo, fetchImpl: githubApp.installationFetch });
3862
+ if (deploy !== void 0) {
3863
+ for (const ref of Object.values(ENVIRONMENT_REFS)) {
3864
+ if (await deploy.getRefSha(ref) === null) {
3865
+ throwOgygieError({
3866
+ code: "HOST_ENVIRONMENT_REF_MISSING",
3867
+ message: `The hosted studio cannot start: the environment ref ${ref} does not exist on ${config.repo.owner}/${config.repo.repo}.`,
3868
+ file: options.configPath,
3869
+ cause: `environments declared, ${ref} absent`,
3870
+ suggestedFix: "Create env/staging and env/production from the default branch and protect them (docs/HOSTED-STUDIO.md \xA77), or remove the environments block."
3871
+ });
3872
+ }
3873
+ }
3874
+ }
3875
+ const app = await createStudioApp({
3876
+ uiDistDir,
3877
+ hosted: {
3878
+ config,
3879
+ sessionSecret,
3880
+ mail: createHttpMailSender({
3881
+ providerUrl: config.mail.providerUrl,
3882
+ apiKey: mailApiKey,
3883
+ fromAddress: config.mail.fromAddress,
3884
+ fetchImpl: fetch
3885
+ }),
3886
+ now,
3887
+ log,
3888
+ consumedLinks: /* @__PURE__ */ new Map(),
3889
+ pulls: createGithubPullsClient({ github: config.repo, fetchImpl: githubApp.installationFetch }),
3890
+ readerFor,
3891
+ usage: createGithubUsageClient({ github: config.repo, fetchImpl: githubApp.installationFetch }),
3892
+ ...deploy === void 0 ? {} : { deploy },
3893
+ ...modelApiKey === void 0 || config.model === void 0 ? {} : { writer: createAnthropicSpecWriter({ apiKey: modelApiKey, model: config.model.specModel, fetchImpl: fetch }) },
3894
+ storageFor: (user) => createHostedSpecStorage({
3895
+ repoId,
3896
+ github: config.repo,
3897
+ readerFor,
3898
+ listSpecBranches,
3899
+ client,
3900
+ user,
3901
+ now,
3902
+ rateLimitPerHour: config.writeRateLimit,
3903
+ recentProposals,
3904
+ log
3905
+ })
3906
+ }
3907
+ });
3908
+ const host = options.host ?? "127.0.0.1";
3909
+ const port = options.port ?? 4001;
3910
+ await app.listen({ port, host });
3911
+ console.log(`ogygie studio \u2014 mode h\xE9berg\xE9 sur ${host}:${port}, repo ${config.repo.owner}/${config.repo.repo}, ${config.users.length} utilisateur(s) d\xE9clar\xE9(s), briefs ${modelApiKey === void 0 ? "d\xE9sactiv\xE9s (pas de bloc model)" : `par ${config.model?.specModel ?? ""}`}`);
3912
+ return app;
3913
+ }
1162
3914
  export {
3915
+ loadHostConfig,
3916
+ startHostedStudio,
1163
3917
  startStudio,
1164
3918
  studioUiDir
1165
3919
  };