@ogygie/studio 0.26.0 → 0.28.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
@@ -826,6 +826,15 @@ function compositionOfSpec(content) {
826
826
  const section = next === null ? rest : rest.slice(0, next.index);
827
827
  return COMPOSITION_LINE.exec(section)?.[1] ?? null;
828
828
  }
829
+ var ASSUMPTIONS_SECTION_TITLE = "Hypoth\xE8ses";
830
+ function assumptionsOfSpec(content) {
831
+ const heading = new RegExp(`^# ${ASSUMPTIONS_SECTION_TITLE}\\s*$`, "mu").exec(content);
832
+ if (heading === null) return [];
833
+ const rest = content.slice(heading.index + heading[0].length);
834
+ const next = /^# /mu.exec(rest);
835
+ const section = next === null ? rest : rest.slice(0, next.index);
836
+ return section.split("\n").map((line) => line.trim()).filter((line) => /^[-*]\s+\S/u.test(line)).map((line) => line.replace(/^[-*]\s+/u, "").trim());
837
+ }
829
838
 
830
839
  // ../ogygie-core/src/proposal.ts
831
840
  var DECISION_FILES = ["DECISIONS.md", "DECISIONS.product.md"];
@@ -1326,6 +1335,16 @@ function parseSpec(filePath, fileName, content) {
1326
1335
  }
1327
1336
  return { ok: true, spec: { frontmatter: checked.data, body: split.body } };
1328
1337
  }
1338
+ function intentOf(body) {
1339
+ const heading = /^# @intent\s*$/mu.exec(body);
1340
+ if (heading === null) return "";
1341
+ const rest = body.slice(heading.index + heading[0].length);
1342
+ const next = /^# /mu.exec(rest);
1343
+ const section = (next === null ? rest : rest.slice(0, next.index)).trim();
1344
+ const paragraph = section.split(/\n\s*\n/u)[0]?.replace(/\s+/gu, " ").trim() ?? "";
1345
+ const sentence = /^.*?[.!?](?:\s|$)/u.exec(paragraph)?.[0]?.trim() ?? paragraph;
1346
+ return sentence.length > 160 ? `${sentence.slice(0, 157).trimEnd()}\u2026` : sentence;
1347
+ }
1329
1348
  function toReadableSummary(filePath, spec) {
1330
1349
  return {
1331
1350
  id: spec.frontmatter.id,
@@ -1333,7 +1352,8 @@ function toReadableSummary(filePath, spec) {
1333
1352
  priority: spec.frontmatter.priority,
1334
1353
  depends_on: spec.frontmatter.depends_on,
1335
1354
  filePath,
1336
- presentSections: presentSectionsOf(spec.body)
1355
+ presentSections: presentSectionsOf(spec.body),
1356
+ intent: intentOf(spec.body)
1337
1357
  };
1338
1358
  }
1339
1359
  async function listSpecFileNames(deps) {
@@ -1540,7 +1560,9 @@ var ReadableSpecSummarySchema = z6.object({
1540
1560
  priority: SpecFrontmatterSchema.shape.priority,
1541
1561
  depends_on: z6.array(FeatureNameSchema),
1542
1562
  filePath: z6.string(),
1543
- presentSections: z6.array(z6.string())
1563
+ presentSections: z6.array(z6.string()),
1564
+ /** The first sentence of `# @intent`, for a list that reads (v1.20: the target-spec suggestions of the brief). */
1565
+ intent: z6.string().default("")
1544
1566
  });
1545
1567
  var UnreadableSpecSummarySchema = z6.object({
1546
1568
  id: z6.string(),
@@ -1584,16 +1606,31 @@ var BriefAttachmentSchema = z6.object({
1584
1606
  name: z6.string().min(1).max(200),
1585
1607
  contentBase64: z6.string().min(1).max(57e5)
1586
1608
  });
1609
+ var BriefAnswerSchema = z6.object({ questionId: FeatureNameSchema, answer: z6.string().min(1).max(2e3) });
1587
1610
  var BriefInputSchema = z6.object({
1588
1611
  brief: z6.string().min(50).max(2e4),
1589
1612
  /** Absent: the model names the new spec. Present and existing: an amendment. */
1590
1613
  specId: FeatureNameSchema.optional(),
1591
1614
  /** The client's material — a mock-up, a workbook, a document; five at most, formats md, txt, csv, json, html, xlsx, pdf. */
1592
- attachments: z6.array(BriefAttachmentSchema).max(5).default([])
1615
+ attachments: z6.array(BriefAttachmentSchema).max(5).default([]),
1616
+ /** D57: the answers of the past rounds of questions, three rounds at most. */
1617
+ answers: z6.array(BriefAnswerSchema).max(15).default([]),
1618
+ /** D57: the round this send opens; at 3 the draft is asked without a question. */
1619
+ round: z6.number().int().min(1).max(4).default(1),
1620
+ /** D57: « Passer les questions » — the draft is asked, the unknown becomes an assumption. */
1621
+ skipQuestions: z6.boolean().default(false)
1622
+ });
1623
+ var BriefQuestionSchema = z6.object({
1624
+ /** kebab-case, stable from one read to the next. */
1625
+ id: FeatureNameSchema,
1626
+ text: z6.string().min(1),
1627
+ /** The proposed answers; a free answer is always possible. */
1628
+ options: z6.array(z6.string().min(1)).min(2).max(4)
1593
1629
  });
1594
1630
  var BriefOutputSchema = z6.object({
1595
1631
  specId: FeatureNameSchema,
1596
- attempts: z6.number().int().min(1).max(2),
1632
+ /** 1 when the first draft passed; up to 4 when the model corrected itself and a refused round of questions cost a turn (D57). */
1633
+ attempts: z6.number().int().min(1).max(4),
1597
1634
  problemsFixed: z6.array(z6.string()),
1598
1635
  /** No feature of the manifest carries this id: its first schemas.ts awaits the operator (D52, lot C). */
1599
1636
  isNewFeature: z6.boolean(),
@@ -1602,10 +1639,30 @@ var BriefOutputSchema = z6.object({
1602
1639
  compositions: z6.array(z6.string()).default([]),
1603
1640
  /** 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. */
1604
1641
  generation: z6.object({ derivable: z6.number().int().nonnegative(), hole: z6.number().int().nonnegative() }).nullable().default(null),
1642
+ /** D57: the points the model decided that the brief did not say — the `# Hypothèses` lines of the draft. */
1643
+ assumptions: z6.array(z6.string()).default([]),
1605
1644
  usage: z6.array(
1606
1645
  z6.object({ model: z6.string(), inputTokens: z6.number().int().nonnegative(), outputTokens: z6.number().int().nonnegative() })
1607
1646
  )
1608
1647
  });
1648
+ var BriefStartOutputSchema = z6.object({ jobId: z6.string() });
1649
+ var BriefStageSchema = z6.enum(["questions", "drafting", "judging", "compositions", "writing", "done", "failed"]);
1650
+ var BriefJobSchema = z6.object({
1651
+ jobId: z6.string(),
1652
+ stage: BriefStageSchema,
1653
+ /** On questions: the round in progress. */
1654
+ round: z6.number().int().min(1).max(3).optional(),
1655
+ /** On drafting / judging: the attempt. */
1656
+ attempt: z6.number().int().min(1).max(4).optional(),
1657
+ /** On compositions: k of N. */
1658
+ composition: z6.object({ done: z6.number().int().nonnegative(), total: z6.number().int().nonnegative() }).optional(),
1659
+ elapsedMs: z6.number().int().nonnegative(),
1660
+ /** The P95 of B3: 90 000 ms. */
1661
+ budgetMs: z6.number().int().positive(),
1662
+ questions: z6.array(BriefQuestionSchema).min(3).max(5).optional(),
1663
+ result: BriefOutputSchema.optional(),
1664
+ error: OgygieErrorSchema.optional()
1665
+ });
1609
1666
  var ChecksStateSchema = z6.enum(["green", "red", "pending", "none"]);
1610
1667
  var PullRequestStatusSchema = z6.object({
1611
1668
  specId: FeatureNameSchema,
@@ -1648,6 +1705,26 @@ var EnvironmentsStatusSchema = z6.object({
1648
1705
  /** release/<date>.<n>, most recent first. */
1649
1706
  releases: z6.array(z6.string())
1650
1707
  });
1708
+ var EnvironmentReadParamsSchema = z6.object({ environment: EnvironmentNameSchema });
1709
+ var CommitReachSchema = z6.enum(["deployed", "forward", "backward", "unreachable"]);
1710
+ var EnvironmentCommitSchema = z6.object({
1711
+ sha: z6.string(),
1712
+ /** The first line of the message without its `spec: merge` prefix and its `(#n)` suffix. */
1713
+ title: z6.string(),
1714
+ specId: FeatureNameSchema.nullable(),
1715
+ author: z6.string(),
1716
+ authoredAt: z6.string(),
1717
+ releaseTag: z6.string().nullable(),
1718
+ reach: CommitReachSchema,
1719
+ /** The reason, in the client's language. */
1720
+ why: z6.string()
1721
+ });
1722
+ var EnvironmentCommitsSchema = z6.object({
1723
+ environment: EnvironmentNameSchema,
1724
+ ref: z6.string(),
1725
+ deployedSha: z6.string().nullable(),
1726
+ commits: z6.array(EnvironmentCommitSchema).max(30)
1727
+ });
1651
1728
  var PromotionInputSchema = z6.object({
1652
1729
  sha: z6.string().regex(/^[0-9a-f]{40}$/),
1653
1730
  /** Operator only: a step back, forced, with its reason. */
@@ -2031,6 +2108,9 @@ ${link}
2031
2108
  });
2032
2109
  }
2033
2110
 
2111
+ // src/server/hosted/brief-routes.ts
2112
+ import { z as z10 } from "zod";
2113
+
2034
2114
  // src/server/hosted/attachments.ts
2035
2115
  import { createHash } from "node:crypto";
2036
2116
  import { inflateRawSync } from "node:zlib";
@@ -2276,6 +2356,9 @@ var MAX_COMPOSITIONS = 3;
2276
2356
  var COMPOSITION_SEPARATOR = "=== COMPOSITION ===";
2277
2357
  var DERIVED_BRIEF_MAX = 6e3;
2278
2358
  var BRIEF_IN_MESSAGE_MAX = 2e3;
2359
+ var MAX_QUESTION_ROUNDS = 3;
2360
+ var QUESTIONS_MARKER = /^\s*QUESTIONS\s*$/mu;
2361
+ var NO_MORE_QUESTIONS = "no more questions: write the draft now, and put what you do not know under # Hypoth\xE8ses";
2279
2362
  async function readIfPresent(storage, path) {
2280
2363
  return await storage.exists(path) ? storage.readFile(path) : null;
2281
2364
  }
@@ -2339,6 +2422,25 @@ async function existingSpecIds(storage) {
2339
2422
  const entries = await storage.listDirectory("specs");
2340
2423
  return entries.map((entry) => entry.path).filter((path) => !path.includes("/") && path.endsWith(".spec.md") && !path.startsWith("_")).map((path) => path.replace(/\.spec\.md$/, "")).sort();
2341
2424
  }
2425
+ function extractQuestions(text) {
2426
+ const marker = QUESTIONS_MARKER.exec(text);
2427
+ if (marker === null) return null;
2428
+ const raw = text.slice(marker.index + marker[0].length).replace(/^\s*```(?:json)?\s*/u, "").replace(/\s*```\s*$/u, "").trim();
2429
+ let parsed;
2430
+ try {
2431
+ parsed = JSON.parse(raw);
2432
+ } catch {
2433
+ return { problem: "QUESTIONS: the JSON after the marker does not parse" };
2434
+ }
2435
+ const shape = z9.object({
2436
+ questions: z9.array(z9.object({ id: FeatureNameSchema, text: z9.string().trim().min(1), options: z9.array(z9.string().trim().min(1)).min(2).max(4) })).min(3).max(5)
2437
+ });
2438
+ const checked = shape.safeParse(parsed);
2439
+ if (!checked.success) return { problem: `QUESTIONS: ${checked.error.issues.map((issue) => `${issue.path.join(".")}: ${issue.message}`).join(" ; ")}` };
2440
+ const ids = new Set(checked.data.questions.map((question) => question.id));
2441
+ if (ids.size !== checked.data.questions.length) return { problem: "QUESTIONS: two questions share an id" };
2442
+ return { questions: checked.data.questions };
2443
+ }
2342
2444
  function buildBriefPrompt(context) {
2343
2445
  const system = [
2344
2446
  "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.",
@@ -2358,6 +2460,8 @@ function buildBriefPrompt(context) {
2358
2460
  "- 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.",
2359
2461
  ...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."],
2360
2462
  "- Tu ne proposes jamais `status: validated` ni `implemented` : valider est le geste de l'humain.",
2463
+ "- HYPOTH\xC8SES : quand tu tranches un point que le brief ne dit pas (un nombre, un ordre, un p\xE9rim\xE8tre, un mot), ajoute apr\xE8s `# Budget` (et avant `# Design` si elle existe) une section `# Hypoth\xE8ses`, une puce par choix, en fran\xE7ais, courte ; sans hypoth\xE8se, pas de section.",
2464
+ '- QUESTIONS : quand la demande ci-dessous t\'y autorise et qu\'une r\xE9ponse du client CHANGERAIT la spec, tu peux, AU LIEU du fichier, r\xE9pondre par une ligne `QUESTIONS` seule puis un objet JSON `{"questions":[{"id":"kebab-case","text":"la question, en fran\xE7ais","options":["r\xE9ponse","r\xE9ponse"]}]}` \u2014 trois \xE0 cinq questions ferm\xE9es, deux \xE0 quatre options chacune, rien d\'autre avant ni apr\xE8s. Une question dont la r\xE9ponse ne changerait pas la spec ne se pose pas : elle devient une hypoth\xE8se.',
2361
2465
  "- 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.",
2362
2466
  "",
2363
2467
  "GABARIT DU REPO (\xE0 respecter \xE0 la lettre) :",
@@ -2379,7 +2483,10 @@ function buildBriefPrompt(context) {
2379
2483
  ...context.existingSpec === null ? [] : ["SPEC EXISTANTE :", context.existingSpec, ""],
2380
2484
  "BRIEF :",
2381
2485
  context.brief.trim(),
2382
- ...attachmentsPromptSection(context.attachments ?? [])
2486
+ ...attachmentsPromptSection(context.attachments ?? []),
2487
+ ...(context.answers ?? []).length === 0 ? [] : ["", "R\xC9PONSES DU CLIENT \xC0 TES QUESTIONS :", ...(context.answers ?? []).map((answer) => `- ${answer.questionId} : ${answer.answer}`)],
2488
+ "",
2489
+ context.questions?.allowed === true ? `Tourn\xE9e ${context.questions.round} sur ${MAX_QUESTION_ROUNDS} : si une r\xE9ponse du client changerait la spec, pose tes questions (format QUESTIONS) ; sinon, r\xE9dige le fichier.` : "N'interroge plus : r\xE9dige le fichier maintenant, et consigne ce que tu ne sais pas sous `# Hypoth\xE8ses`."
2383
2490
  ].join("\n");
2384
2491
  return { system, user, documents: attachmentDocumentsOf(context.attachments ?? []) };
2385
2492
  }
@@ -2471,7 +2578,7 @@ function judgeDraft(input) {
2471
2578
  }
2472
2579
  return { specId, problems: [...new Set(problems)] };
2473
2580
  }
2474
- async function draftSpecFromBrief(deps, input) {
2581
+ async function runBrief(deps, input) {
2475
2582
  if (input.specId !== void 0 && !FeatureNameSchema.safeParse(input.specId).success) {
2476
2583
  throwOgygieError(
2477
2584
  {
@@ -2502,6 +2609,8 @@ async function draftSpecFromBrief(deps, input) {
2502
2609
  const knownDecisionIds = (await Promise.all(DECISION_FILES.map((file) => readIfPresent(deps.storage, file)))).flatMap((content) => parseKnownDecisionIds(content ?? ""));
2503
2610
  const existingSpec = input.specId !== void 0 ? await readIfPresent(deps.storage, `specs/${input.specId}.spec.md`) : null;
2504
2611
  const isAmendment = existingSpec !== null;
2612
+ const round = Math.min(Math.max(input.round ?? 1, 1), MAX_QUESTION_ROUNDS + 1);
2613
+ const questionsAllowed = input.skipQuestions !== true && round <= MAX_QUESTION_ROUNDS;
2505
2614
  const prompt = buildBriefPrompt({
2506
2615
  template,
2507
2616
  productRules,
@@ -2512,15 +2621,40 @@ async function draftSpecFromBrief(deps, input) {
2512
2621
  existingSpec,
2513
2622
  specId: input.specId,
2514
2623
  brief: input.brief,
2515
- attachments: input.attachments
2624
+ attachments: input.attachments,
2625
+ answers: input.answers,
2626
+ questions: { allowed: questionsAllowed, round }
2516
2627
  });
2517
2628
  const usage = [];
2629
+ let refusedQuestions = false;
2518
2630
  let previous;
2519
2631
  let firstProblems = [];
2520
2632
  let maxAttempts = MAX_ATTEMPTS;
2521
2633
  for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
2634
+ deps.onStage?.({ stage: "drafting", attempt });
2522
2635
  const answer = await deps.writer.write({ ...prompt, previous });
2523
2636
  usage.push({ model: answer.model, ...answer.usage });
2637
+ const asked = extractQuestions(answer.text);
2638
+ if (asked !== null) {
2639
+ if ("problem" in asked) {
2640
+ deps.log(JSON.stringify({ action: "brief", attempt, questions: "malformed", ...answer.usage }));
2641
+ if (attempt === 1) firstProblems = [asked.problem];
2642
+ previous = { draft: answer.text, problems: [asked.problem] };
2643
+ continue;
2644
+ }
2645
+ if (questionsAllowed) {
2646
+ deps.log(JSON.stringify({ action: "brief", attempt, round, questions: asked.questions.length, ...answer.usage }));
2647
+ return { kind: "questions", round, questions: asked.questions, usage };
2648
+ }
2649
+ deps.log(JSON.stringify({ action: "brief", attempt, round, questions: "refused", ...answer.usage }));
2650
+ if (!refusedQuestions) {
2651
+ refusedQuestions = true;
2652
+ maxAttempts += 1;
2653
+ }
2654
+ previous = { draft: answer.text, problems: [NO_MORE_QUESTIONS] };
2655
+ continue;
2656
+ }
2657
+ deps.onStage?.({ stage: "judging", attempt });
2524
2658
  const content = extractSpecText(answer.text);
2525
2659
  const judged = judgeDraft({
2526
2660
  content,
@@ -2535,7 +2669,11 @@ async function draftSpecFromBrief(deps, input) {
2535
2669
  deps.log(JSON.stringify({ action: "brief", attempt, specId: judged.specId, problems: judged.problems.length, generated: generationJudgement.tally !== null, ...answer.usage }));
2536
2670
  if (judged.problems.length === 0 && judged.specId !== null) {
2537
2671
  const specId = judged.specId;
2538
- const briefForMessage = (input.brief.length > BRIEF_IN_MESSAGE_MAX ? `${input.brief.slice(0, BRIEF_IN_MESSAGE_MAX)} (\u2026)` : input.brief) + attachmentsProvenance(input.attachments ?? []);
2672
+ const assumptions = assumptionsOfSpec(content);
2673
+ const briefForMessage = (input.brief.length > BRIEF_IN_MESSAGE_MAX ? `${input.brief.slice(0, BRIEF_IN_MESSAGE_MAX)} (\u2026)` : input.brief) + attachmentsProvenance(input.attachments ?? []) + (assumptions.length === 0 ? "" : `
2674
+
2675
+ Hypoth\xE8ses du mod\xE8le (D57) :
2676
+ ${assumptions.map((line) => `- ${line}`).join("\n")}`);
2539
2677
  const composition = isAmendment ? null : compositionOfSpec(content);
2540
2678
  const compositions = [];
2541
2679
  if (composition !== null) {
@@ -2553,7 +2691,9 @@ async function draftSpecFromBrief(deps, input) {
2553
2691
  deps.log(JSON.stringify({ action: "composition", specId, outcome: thrown instanceof OgygieFailure ? thrown.error.code : "error" }));
2554
2692
  }
2555
2693
  const seen = /* @__PURE__ */ new Set([composition]);
2556
- for (const candidate of splitCompositions(answerText)) {
2694
+ const candidates = splitCompositions(answerText);
2695
+ deps.onStage?.({ stage: "compositions", composition: { done: 0, total: Math.min(candidates.length, MAX_COMPOSITIONS - 1) } });
2696
+ for (const candidate of candidates) {
2557
2697
  if (seen.size >= MAX_COMPOSITIONS) break;
2558
2698
  const name = compositionOfSpec(candidate);
2559
2699
  const verdict = judgeDraft({ content: candidate, expectedId: specId, knownDecisionIds, takenIds: specIds, isAmendment: false });
@@ -2561,6 +2701,7 @@ async function draftSpecFromBrief(deps, input) {
2561
2701
  deps.log(JSON.stringify({ action: "composition", specId, composition: name, problems: problems.length }));
2562
2702
  if (problems.length > 0 || name === null) continue;
2563
2703
  seen.add(name);
2704
+ deps.onStage?.({ stage: "compositions", composition: { done: seen.size - 1, total: Math.min(candidates.length, MAX_COMPOSITIONS - 1) } });
2564
2705
  await deps.storage.write(`specs/${specId}.spec.md`, candidate, `spec: composition ${name} pour ${specId}, propos\xE9e par le mod\xE8le
2565
2706
 
2566
2707
  Une autre disposition du m\xEAme \xE9cran ; la composition retenue par d\xE9faut suit dans le commit suivant.
@@ -2571,6 +2712,7 @@ ${briefForMessage}`);
2571
2712
  }
2572
2713
  compositions.unshift(composition);
2573
2714
  }
2715
+ deps.onStage?.({ stage: "writing" });
2574
2716
  await deps.storage.write(
2575
2717
  `specs/${specId}.spec.md`,
2576
2718
  content,
@@ -2580,14 +2722,18 @@ Brief :
2580
2722
  ${briefForMessage}`
2581
2723
  );
2582
2724
  return {
2583
- specId,
2584
- attempts: attempt,
2585
- problemsFixed: firstProblems,
2586
- isNewFeature: !manifest.featureIds.has(specId),
2587
- isAmendment,
2588
- compositions,
2589
- generation: generationJudgement.tally,
2590
- usage
2725
+ kind: "draft",
2726
+ result: {
2727
+ specId,
2728
+ attempts: attempt,
2729
+ problemsFixed: firstProblems,
2730
+ isNewFeature: !manifest.featureIds.has(specId),
2731
+ isAmendment,
2732
+ compositions,
2733
+ generation: generationJudgement.tally,
2734
+ assumptions,
2735
+ usage
2736
+ }
2591
2737
  };
2592
2738
  }
2593
2739
  if (attempt === 1) firstProblems = judged.problems;
@@ -2606,37 +2752,144 @@ ${briefForMessage}`
2606
2752
  );
2607
2753
  }
2608
2754
 
2755
+ // src/server/hosted/brief-jobs.ts
2756
+ import { randomUUID } from "node:crypto";
2757
+ var BRIEF_BUDGET_MS = 9e4;
2758
+ var BRIEF_JOB_TTL_MS = 15 * 6e4;
2759
+ function createBriefJobs(deps) {
2760
+ const budgetMs = deps.budgetMs ?? BRIEF_BUDGET_MS;
2761
+ const ttlMs = deps.ttlMs ?? BRIEF_JOB_TTL_MS;
2762
+ const idOf = deps.idOf ?? randomUUID;
2763
+ const jobs = /* @__PURE__ */ new Map();
2764
+ const settlements = /* @__PURE__ */ new Map();
2765
+ const sweep = () => {
2766
+ const now = deps.now();
2767
+ for (const [jobId, job] of jobs) {
2768
+ if (job.endedAt !== null && job.endedAt + ttlMs <= now) {
2769
+ jobs.delete(jobId);
2770
+ settlements.delete(jobId);
2771
+ }
2772
+ }
2773
+ };
2774
+ const errorOf = (thrown) => thrown instanceof OgygieFailure ? thrown.error : ogygieError({
2775
+ code: "BRIEF_FAILED",
2776
+ message: "The brief failed before a draft or a round of questions came out.",
2777
+ file: null,
2778
+ cause: thrown instanceof Error ? thrown.message : String(thrown),
2779
+ suggestedFix: "Send the brief again; if it repeats, read the instance log."
2780
+ });
2781
+ return {
2782
+ start(owner, run) {
2783
+ sweep();
2784
+ const running = [...jobs.values()].find((job2) => job2.owner === owner && job2.endedAt === null);
2785
+ if (running !== void 0) {
2786
+ throwOgygieError(
2787
+ {
2788
+ code: "BRIEF_IN_PROGRESS",
2789
+ message: "A brief of yours is still running: one at a time.",
2790
+ file: null,
2791
+ cause: `job ${running.jobId} started ${deps.now() - running.startedAt} ms ago`,
2792
+ suggestedFix: "Wait for it to end (GET /api/briefs/<id>), then send the next brief.",
2793
+ details: { jobId: running.jobId }
2794
+ },
2795
+ 409
2796
+ );
2797
+ }
2798
+ const job = { jobId: idOf(), owner, startedAt: deps.now(), endedAt: null, progress: null, outcome: null, error: null };
2799
+ jobs.set(job.jobId, job);
2800
+ const settlement = run((progress) => {
2801
+ job.progress = progress;
2802
+ }).then((outcome) => {
2803
+ job.outcome = outcome;
2804
+ }).catch((thrown) => {
2805
+ job.error = errorOf(thrown);
2806
+ }).finally(() => {
2807
+ job.endedAt = deps.now();
2808
+ });
2809
+ settlements.set(job.jobId, settlement);
2810
+ return job.jobId;
2811
+ },
2812
+ read(jobId) {
2813
+ sweep();
2814
+ const job = jobs.get(jobId);
2815
+ if (job === void 0) {
2816
+ return throwOgygieError(
2817
+ {
2818
+ code: "BRIEF_JOB_LOST",
2819
+ message: "This brief job is unknown here: it expired, or the instance restarted since.",
2820
+ file: null,
2821
+ cause: `no job ${jobId} in memory`,
2822
+ suggestedFix: "Send the brief again; its text is still in your editor."
2823
+ },
2824
+ 404
2825
+ );
2826
+ }
2827
+ const base = { jobId: job.jobId, elapsedMs: Math.max(0, (job.endedAt ?? deps.now()) - job.startedAt), budgetMs };
2828
+ if (job.error !== null) return { ...base, stage: "failed", error: job.error };
2829
+ if (job.outcome !== null) {
2830
+ return job.outcome.kind === "draft" ? { ...base, stage: "done", result: job.outcome.result } : { ...base, stage: "questions", round: job.outcome.round, questions: job.outcome.questions };
2831
+ }
2832
+ const progress = job.progress ?? { stage: "drafting", attempt: 1 };
2833
+ switch (progress.stage) {
2834
+ case "drafting":
2835
+ case "judging":
2836
+ return { ...base, stage: progress.stage, attempt: progress.attempt };
2837
+ case "compositions":
2838
+ return { ...base, stage: "compositions", composition: progress.composition };
2839
+ case "writing":
2840
+ return { ...base, stage: "writing" };
2841
+ }
2842
+ },
2843
+ async settled(jobId) {
2844
+ await (settlements.get(jobId) ?? Promise.resolve());
2845
+ }
2846
+ };
2847
+ }
2848
+
2609
2849
  // src/server/hosted/brief-routes.ts
2610
2850
  var BRIEF_BODY_LIMIT = 16 * 1024 * 1024;
2611
2851
  async function registerBriefRoutes(app, deps) {
2612
2852
  const route = app.withTypeProvider();
2853
+ const now = deps.now ?? Date.now;
2854
+ const jobs = deps.jobs ?? createBriefJobs({ now });
2613
2855
  route.route({
2614
2856
  method: "POST",
2615
2857
  url: "/api/briefs",
2616
2858
  bodyLimit: BRIEF_BODY_LIMIT,
2617
- schema: { body: BriefInputSchema, response: { 201: BriefOutputSchema } },
2859
+ schema: { body: BriefInputSchema, response: { 202: BriefStartOutputSchema } },
2618
2860
  handler: async (request, reply) => {
2619
2861
  const user = hostedUserOf(request);
2620
2862
  requireRole(user, "author", "write a brief");
2621
2863
  const attachments = prepareBriefAttachments(request.body.attachments ?? []);
2622
2864
  const storage = deps.storageFor(user);
2623
- const result = await draftSpecFromBrief(
2624
- { storage, writer: deps.writer, log: deps.log },
2625
- { brief: request.body.brief, specId: request.body.specId, attachments }
2626
- );
2627
- const proposal = "lastProposal" in storage ? storage.lastProposal : null;
2628
- if (deps.usage !== void 0 && proposal !== null) {
2629
- const at = new Date((deps.now ?? Date.now)()).toISOString();
2630
- try {
2631
- await deps.usage.appendUsage(
2632
- proposal.pullRequestNumber,
2633
- 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 }))
2634
- );
2635
- } catch (thrown) {
2636
- deps.log(JSON.stringify({ action: "usage-journal", specId: result.specId, outcome: thrown instanceof Error ? thrown.message : String(thrown) }));
2865
+ const { brief, specId, answers, round, skipQuestions } = request.body;
2866
+ const jobId = jobs.start(user.email, async (onStage) => {
2867
+ const outcome = await runBrief({ storage, writer: deps.writer, log: deps.log, onStage }, { brief, specId, attachments, answers, round, skipQuestions });
2868
+ if (outcome.kind !== "draft") return outcome;
2869
+ const proposal = "lastProposal" in storage ? storage.lastProposal : null;
2870
+ if (deps.usage !== void 0 && proposal !== null) {
2871
+ const at = new Date(now()).toISOString();
2872
+ try {
2873
+ await deps.usage.appendUsage(
2874
+ proposal.pullRequestNumber,
2875
+ outcome.result.usage.map((usage, index) => ({ at, action: "brief", attempt: index + 1, model: usage.model, inputTokens: usage.inputTokens, outputTokens: usage.outputTokens, actor: user.email }))
2876
+ );
2877
+ } catch (thrown) {
2878
+ deps.log(JSON.stringify({ action: "usage-journal", specId: outcome.result.specId, outcome: thrown instanceof Error ? thrown.message : String(thrown) }));
2879
+ }
2637
2880
  }
2638
- }
2639
- return reply.code(201).send(result);
2881
+ return outcome;
2882
+ });
2883
+ return reply.code(202).send({ jobId });
2884
+ }
2885
+ });
2886
+ route.route({
2887
+ method: "GET",
2888
+ url: "/api/briefs/:jobId",
2889
+ schema: { params: z10.object({ jobId: z10.string().min(1) }), response: { 200: BriefJobSchema } },
2890
+ handler: async (request, reply) => {
2891
+ hostedUserOf(request);
2892
+ return reply.code(200).send(jobs.read(request.params.jobId));
2640
2893
  }
2641
2894
  });
2642
2895
  }
@@ -3004,6 +3257,48 @@ async function environmentsStatus(deps) {
3004
3257
  releases: releases.sort().reverse()
3005
3258
  };
3006
3259
  }
3260
+ var LINE_LENGTH = 30;
3261
+ function commitTitleOf(message) {
3262
+ const firstLine = message.split("\n")[0] ?? "";
3263
+ return firstLine.replace(/^spec: merge /u, "").replace(/\s*\(#\d+\)\s*$/u, "").trim();
3264
+ }
3265
+ function reachOf(environment, index, deployedIndex, stagingIndex) {
3266
+ if (index === deployedIndex) return { reach: "deployed", why: "d\xE9ploy\xE9" };
3267
+ if (environment === "development") return { reach: "unreachable", why: "le d\xE9veloppement suit la branche par d\xE9faut, il ne se promeut pas" };
3268
+ const newer = deployedIndex === -1 || index < deployedIndex;
3269
+ if (environment === "staging") {
3270
+ return newer ? { reach: "forward", why: "avance rapide depuis le d\xE9veloppement" } : { reach: "backward", why: "retour arri\xE8re : op\xE9rateur, avec un motif" };
3271
+ }
3272
+ if (index === stagingIndex && newer) return { reach: "forward", why: "le commit de la pr\xE9production" };
3273
+ if (newer) return { reach: "unreachable", why: stagingIndex !== -1 && index < stagingIndex ? "la pr\xE9production ne l'a pas re\xE7u (PROMOTION_ORDER_VIOLATED)" : "seul le commit de la pr\xE9production est promouvable" };
3274
+ return { reach: "backward", why: "retour arri\xE8re : op\xE9rateur, avec un motif" };
3275
+ }
3276
+ async function environmentCommits(deps, environment) {
3277
+ const ref = environment === "development" ? deps.defaultBranch : ENVIRONMENT_REFS[environment];
3278
+ const [deployedSha, stagingSha, commits, tags] = await Promise.all([
3279
+ deps.deploy.getRefSha(ref),
3280
+ environment === "production" ? deps.deploy.getRefSha(ENVIRONMENT_REFS.staging) : Promise.resolve(null),
3281
+ deps.deploy.listCommits(deps.defaultBranch, LINE_LENGTH),
3282
+ deps.deploy.listReleaseTags("release/")
3283
+ ]);
3284
+ const tagOf = new Map(tags.map((tag) => [tag.sha, tag.name]));
3285
+ const deployedIndex = commits.findIndex((commit) => commit.sha === deployedSha);
3286
+ const stagingIndex = stagingSha === null ? -1 : commits.findIndex((commit) => commit.sha === stagingSha);
3287
+ return {
3288
+ environment,
3289
+ ref,
3290
+ deployedSha,
3291
+ commits: commits.map((commit, index) => ({
3292
+ sha: commit.sha,
3293
+ title: commitTitleOf(commit.message),
3294
+ specId: MERGED_SPEC.exec(commit.message.split("\n")[0] ?? "")?.[1] ?? null,
3295
+ author: commit.author,
3296
+ authoredAt: commit.authoredAt,
3297
+ releaseTag: tagOf.get(commit.sha) ?? null,
3298
+ ...reachOf(environment, index, deployedIndex, stagingIndex)
3299
+ }))
3300
+ };
3301
+ }
3007
3302
  function pullRequestsOf(commits) {
3008
3303
  const included = [];
3009
3304
  for (const commit of commits) {
@@ -3134,6 +3429,15 @@ async function registerPromotionRoutes(app, deps) {
3134
3429
  return reply.code(200).send(await environmentsStatus(promotionDeps));
3135
3430
  }
3136
3431
  });
3432
+ route.route({
3433
+ method: "GET",
3434
+ url: "/api/environments/:environment/commits",
3435
+ schema: { params: EnvironmentReadParamsSchema, response: { 200: EnvironmentCommitsSchema } },
3436
+ handler: async (request, reply) => {
3437
+ hostedUserOf(request);
3438
+ return reply.code(200).send(await environmentCommits(promotionDeps, request.params.environment));
3439
+ }
3440
+ });
3137
3441
  route.route({
3138
3442
  method: "POST",
3139
3443
  url: "/api/environments/:environment/promotions",
@@ -3147,17 +3451,17 @@ async function registerPromotionRoutes(app, deps) {
3147
3451
  }
3148
3452
 
3149
3453
  // src/server/hosted/usage.ts
3150
- import { z as z10 } from "zod";
3151
- var UsageEntrySchema = z10.object({
3152
- at: z10.string(),
3454
+ import { z as z11 } from "zod";
3455
+ var UsageEntrySchema = z11.object({
3456
+ at: z11.string(),
3153
3457
  /** brief → spec from the instance; `speculate` entries arrive with the generator's lot. */
3154
- action: z10.enum(["brief", "speculate"]),
3155
- attempt: z10.number().int().positive().optional(),
3156
- model: z10.string(),
3157
- inputTokens: z10.number().int().nonnegative(),
3158
- outputTokens: z10.number().int().nonnegative(),
3159
- actor: z10.string().optional(),
3160
- specId: z10.string().optional()
3458
+ action: z11.enum(["brief", "speculate"]),
3459
+ attempt: z11.number().int().positive().optional(),
3460
+ model: z11.string(),
3461
+ inputTokens: z11.number().int().nonnegative(),
3462
+ outputTokens: z11.number().int().nonnegative(),
3463
+ actor: z11.string().optional(),
3464
+ specId: z11.string().optional()
3161
3465
  });
3162
3466
  var USAGE_MARKER = "<!-- ogygie-usage -->";
3163
3467
  var DEFAULT_MODEL_COSTS = {
@@ -3192,7 +3496,7 @@ function parseUsageComment(body) {
3192
3496
  const block = /```json\n([\s\S]*?)\n```/.exec(body)?.[1];
3193
3497
  if (block === void 0) return [];
3194
3498
  try {
3195
- const parsed = z10.array(UsageEntrySchema).safeParse(JSON.parse(block));
3499
+ const parsed = z11.array(UsageEntrySchema).safeParse(JSON.parse(block));
3196
3500
  return parsed.success ? parsed.data : [];
3197
3501
  } catch {
3198
3502
  return [];
@@ -3267,9 +3571,9 @@ async function registerConsumptionRoutes(app, deps) {
3267
3571
  }
3268
3572
 
3269
3573
  // src/server/hosted/preview-routes.ts
3270
- import { z as z11 } from "zod";
3271
- var PreviewParamsSchema = SpecParamsSchema.extend({ state: z11.string().min(1).max(80) });
3272
- var CompositionPreviewParamsSchema = PreviewParamsSchema.extend({ composition: z11.string().min(1).max(80) });
3574
+ import { z as z12 } from "zod";
3575
+ var PreviewParamsSchema = SpecParamsSchema.extend({ state: z12.string().min(1).max(80) });
3576
+ var CompositionPreviewParamsSchema = PreviewParamsSchema.extend({ composition: z12.string().min(1).max(80) });
3273
3577
  var PREVIEW_CSP = "sandbox; default-src 'none'; style-src 'unsafe-inline'; img-src data:; font-src data:";
3274
3578
  async function registerPreviewRoutes(app, deps) {
3275
3579
  const route = app.withTypeProvider();
@@ -3311,7 +3615,7 @@ async function registerPreviewRoutes(app, deps) {
3311
3615
  }
3312
3616
 
3313
3617
  // src/server/hosted/composition-routes.ts
3314
- import { z as z12 } from "zod";
3618
+ import { z as z13 } from "zod";
3315
3619
 
3316
3620
  // src/server/hosted/compositions.ts
3317
3621
  var COMPOSITION_NAME = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
@@ -3355,7 +3659,7 @@ La composition \xAB ${composition} \xBB est retenue parmi celles propos\xE9es ;
3355
3659
  }
3356
3660
 
3357
3661
  // src/server/hosted/composition-routes.ts
3358
- var CompositionParamsSchema = SpecParamsSchema.extend({ composition: z12.string().min(1).max(80) });
3662
+ var CompositionParamsSchema = SpecParamsSchema.extend({ composition: z13.string().min(1).max(80) });
3359
3663
  async function registerCompositionRoutes(app, deps) {
3360
3664
  const route = app.withTypeProvider();
3361
3665
  route.route({
@@ -3849,6 +4153,29 @@ function createGithubDeployClient(deps) {
3849
4153
  commits: (answer?.commits ?? []).map((entry) => ({ sha: entry.sha, message: entry.commit.message, authoredAt: entry.commit.author.date }))
3850
4154
  };
3851
4155
  },
4156
+ async listReleaseTags(prefix) {
4157
+ const refs = await request("GET", `${repoUrl}/git/matching-refs/tags/${prefix}`, void 0) ?? [];
4158
+ const tags = [];
4159
+ for (const entry of refs) {
4160
+ const name = entry.ref.replace(/^refs\/tags\//u, "");
4161
+ if (entry.object.type !== "tag") {
4162
+ tags.push({ name, sha: entry.object.sha });
4163
+ continue;
4164
+ }
4165
+ const tag = await request("GET", `${repoUrl}/git/tags/${entry.object.sha}`, void 0, true);
4166
+ if (tag !== null) tags.push({ name, sha: tag.object.sha });
4167
+ }
4168
+ return tags;
4169
+ },
4170
+ async listCommits(ref, perPage) {
4171
+ const answer = await request(
4172
+ "GET",
4173
+ `${repoUrl}/commits?sha=${encodeURIComponent(ref)}&per_page=${Math.min(Math.max(perPage, 1), 100)}`,
4174
+ void 0,
4175
+ true
4176
+ );
4177
+ return (answer ?? []).map((entry) => ({ sha: entry.sha, message: entry.commit.message, authoredAt: entry.commit.author.date, author: entry.commit.author.name }));
4178
+ },
3852
4179
  async updateRef(ref, sha, force) {
3853
4180
  await request("PATCH", `${repoUrl}/git/refs/heads/${encodeURIComponent(ref)}`, { sha, force });
3854
4181
  },