@ogygie/mcp 0.12.0 → 0.13.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -4,7 +4,9 @@ Serveur MCP du framework Ogygie : il donne à un espace de conception
4
4
  (claude.ai, Claude Desktop, Claude Code) un accès **en lecture** aux fichiers
5
5
  de gouvernance et aux features de N repos Ogygie — sans copie, sans recherche
6
6
  sémantique, sans connaître le produit. **Le manifeste est l'index** : une
7
- feature qu'il ne déclare pas n'existe pas.
7
+ feature qu'il ne déclare pas n'existe pas. Depuis la 0.13, il sait aussi
8
+ **proposer** : une spec ou un fichier de décisions devient une pull request,
9
+ jamais un fichier écrit sur l'hôte (D17).
8
10
 
9
11
  ## Lancement
10
12
 
@@ -41,6 +43,8 @@ Sans `--config`, le serveur sert le projet courant sous l'id `local`.
41
43
  | `ogygie_read_decisions` | une décision `D<n>`, ou les deux fichiers entiers |
42
44
  | `ogygie_read_governance` | `AGENTS.md`, `AGENTS.product.md` ou `FAILURES.md` |
43
45
  | `ogygie_read_feature` | les fichiers d'une feature, ou l'un d'eux |
46
+ | `ogygie_impact` | le rayon d'impact d'une feature ou d'un package |
47
+ | `ogygie_propose_change` | (repos avec bloc `github` + jeton) une spec ou un fichier de décisions → commit sur une branche `mcp/…` + pull request |
44
48
 
45
49
  Chaque réponse porte sa provenance `{ repo, path, ref }` : ce qui a été lu, où,
46
50
  et à quel commit.
@@ -54,10 +58,30 @@ qu'un `--host` n'est pas écrit à la main. La synchronisation des clones
54
58
  (`git fetch`) est un problème d'hébergement : le serveur lit ce que le disque
55
59
  porte, et le date par `ref`.
56
60
 
61
+ ## Écriture par proposition
62
+
63
+ Un repo devient écrivable quand son entrée de registre porte ses coordonnées
64
+ GitHub et que l'hôte porte `OGYGIE_MCP_GITHUB_TOKEN` (jeton à portée minimale :
65
+ contenus + pull requests sur les seuls repos servis) :
66
+
67
+ ```json
68
+ { "id": "mon-produit", "rootPath": "/srv/clones/mon-produit",
69
+ "github": { "owner": "mon-org", "repo": "mon-produit", "defaultBranch": "main" } }
70
+ ```
71
+
72
+ Périmètre fermé : `specs/<id>.spec.md`, `DECISIONS.md`, `DECISIONS.product.md`.
73
+ Le contenu est validé comme `verify-specs` le ferait (frontmatter, sections,
74
+ références `D<n>`, numérotation des décisions), une spec ne passe jamais
75
+ `implemented` par cette voie, un conflit avec la tête distante est refusé avec
76
+ le contenu à jour, et une seconde proposition sur le même chemin met à jour la
77
+ même pull request. `--write-rate-limit <n>` borne les propositions par repo et
78
+ par heure (20 par défaut). Sans bloc `github` ni jeton, l'outil n'est pas
79
+ listé : un serveur de lecture reste un serveur de lecture.
80
+
57
81
  ## Ce qu'il ne fait pas
58
82
 
59
- Aucune écriture — pas de spec créée ni amendée, pas de commit. Aucune exécution
60
- de code du repo servi. Aucun accès hors carte : `.env`, secrets et artefacts de
83
+ Aucune écriture sur le disque de l'hôte, aucune écriture hors des specs et des
84
+ décisions, aucun merge. Aucune exécution de code du repo servi. Aucun accès hors carte : `.env`, secrets et artefacts de
61
85
  build sont inatteignables par construction, pas par liste noire. Aucun cache :
62
86
  chaque lecture touche le disque, la fraîcheur est l'objectif.
63
87
 
package/dist/mcp.js CHANGED
@@ -204,6 +204,104 @@ function createFilesystemRepoAdapter(rootDir) {
204
204
  };
205
205
  }
206
206
 
207
+ // src/github-client.ts
208
+ function createGithubWriteClient(deps, github) {
209
+ const apiBase = deps.apiBase ?? "https://api.github.com";
210
+ const repoUrl = `${apiBase}/repos/${github.owner}/${github.repo}`;
211
+ async function request(method, url, body, allowNotFound) {
212
+ let response;
213
+ try {
214
+ response = await deps.fetchImpl(url, {
215
+ method,
216
+ headers: {
217
+ authorization: `Bearer ${deps.token}`,
218
+ accept: "application/vnd.github+json",
219
+ "x-github-api-version": "2022-11-28",
220
+ "user-agent": "ogygie-mcp",
221
+ ...body === void 0 ? {} : { "content-type": "application/json" }
222
+ },
223
+ ...body === void 0 ? {} : { body: JSON.stringify(body) }
224
+ });
225
+ } catch (thrown) {
226
+ return throwOgygieError({
227
+ code: "GITHUB_UNAVAILABLE",
228
+ message: `GitHub could not be reached for ${github.owner}/${github.repo}.`,
229
+ file: null,
230
+ cause: thrown instanceof Error ? thrown.message : String(thrown),
231
+ suggestedFix: "Retry once the network is back \u2014 nothing was written, nothing is queued."
232
+ });
233
+ }
234
+ if (response.status === 404 && allowNotFound) return null;
235
+ if (!response.ok) {
236
+ return throwOgygieError({
237
+ code: "GITHUB_UNAVAILABLE",
238
+ message: `GitHub answered ${response.status} for ${method} ${url.replace(apiBase, "")}.`,
239
+ file: null,
240
+ cause: `status=${response.status}`,
241
+ suggestedFix: response.status === 401 || response.status === 403 ? "Check OGYGIE_MCP_GITHUB_TOKEN: it must grant contents and pull requests on this repo." : "Retry later \u2014 nothing was written, nothing is queued."
242
+ });
243
+ }
244
+ return await response.json();
245
+ }
246
+ return {
247
+ async getBranchHead(branch) {
248
+ const ref = await request(
249
+ "GET",
250
+ `${repoUrl}/git/ref/heads/${encodeURIComponent(branch)}`,
251
+ void 0,
252
+ true
253
+ );
254
+ return ref?.object.sha ?? null;
255
+ },
256
+ async readFileAtRef(path, ref) {
257
+ const file = await request(
258
+ "GET",
259
+ `${repoUrl}/contents/${path}?ref=${encodeURIComponent(ref)}`,
260
+ void 0,
261
+ true
262
+ );
263
+ if (file === null) return null;
264
+ return { content: Buffer.from(file.content, "base64").toString("utf8"), sha: file.sha };
265
+ },
266
+ async createBranch(branch, fromSha) {
267
+ await request("POST", `${repoUrl}/git/refs`, { ref: `refs/heads/${branch}`, sha: fromSha }, false);
268
+ },
269
+ async commitFile(input) {
270
+ const result = await request(
271
+ "PUT",
272
+ `${repoUrl}/contents/${input.path}`,
273
+ {
274
+ message: input.message,
275
+ content: Buffer.from(input.content, "utf8").toString("base64"),
276
+ branch: input.branch,
277
+ ...input.expectedSha === null ? {} : { sha: input.expectedSha }
278
+ },
279
+ false
280
+ );
281
+ return { commitSha: result?.commit.sha ?? "" };
282
+ },
283
+ async findOpenPullRequest(branch) {
284
+ const pulls = await request(
285
+ "GET",
286
+ `${repoUrl}/pulls?state=open&head=${encodeURIComponent(`${github.owner}:${branch}`)}`,
287
+ void 0,
288
+ false
289
+ );
290
+ const first = pulls?.[0];
291
+ return first === void 0 ? null : { number: first.number, url: first.html_url };
292
+ },
293
+ async openPullRequest(input) {
294
+ const pull = await request(
295
+ "POST",
296
+ `${repoUrl}/pulls`,
297
+ { title: input.title, body: input.body, head: input.branch, base: input.base },
298
+ false
299
+ );
300
+ return { number: pull?.number ?? 0, url: pull?.html_url ?? "" };
301
+ }
302
+ };
303
+ }
304
+
207
305
  // ../ogygie-core/src/manifest.schema.ts
208
306
  import { z as z3 } from "zod";
209
307
  var FeatureNameSchema = z3.string().regex(/^[a-z][a-z0-9]*(-[a-z0-9]+)*$/, "kebab-case required");
@@ -501,6 +599,14 @@ var SpecFrontmatterSchema = z4.object({
501
599
  refs: z4.array(z4.string()).default([]).describe("References to the domain model, e.g. 'modele-gally \xA74.2'"),
502
600
  deferred: z4.array(DeferredBehaviorSchema).default([]).describe("Behaviors deferred to a later feature, blocked by name")
503
601
  });
602
+ var SECTIONS_SPEC_OBLIGATOIRES = [
603
+ "# @intent",
604
+ "# Comportements",
605
+ "# Contrats de donn\xE9es",
606
+ "# \xC9tats UI",
607
+ "# Non-objectifs",
608
+ "# Budget"
609
+ ];
504
610
  function splitInlineArrayElements(inner) {
505
611
  const elements = [];
506
612
  let current = "";
@@ -598,12 +704,19 @@ var GovernanceFileSchema = z5.enum([
598
704
  "AGENTS.product.md",
599
705
  "FAILURES.md"
600
706
  ]);
707
+ var MAX_FILE_BYTES = 2e5;
601
708
  var DECISION_FILES = ["DECISIONS.md", "DECISIONS.product.md"];
709
+ var GithubCoordinatesSchema = z5.object({
710
+ owner: z5.string().min(1),
711
+ repo: z5.string().min(1),
712
+ defaultBranch: z5.string().min(1).default("main")
713
+ });
602
714
  var RepoRegistrySchema = z5.object({
603
715
  repos: z5.array(
604
716
  z5.object({
605
717
  id: RepoIdSchema,
606
- rootPath: z5.string().min(1)
718
+ rootPath: z5.string().min(1),
719
+ github: GithubCoordinatesSchema.optional()
607
720
  })
608
721
  ).min(1)
609
722
  });
@@ -624,6 +737,29 @@ var ReadFeatureInput = {
624
737
  feature: FeatureNameSchema2,
625
738
  file: z5.string().optional()
626
739
  };
740
+ var ProposablePathSchema = z5.union([
741
+ z5.string().regex(/^specs\/(?!_)[a-z][a-z0-9]*(-[a-z0-9]+)*\.spec\.md$/),
742
+ z5.literal("DECISIONS.md"),
743
+ z5.literal("DECISIONS.product.md")
744
+ ]);
745
+ var ProposeChangeInput = {
746
+ repo: RepoIdSchema,
747
+ path: z5.string(),
748
+ content: z5.string().min(1).max(MAX_FILE_BYTES),
749
+ message: z5.string().min(10).max(2e3)
750
+ };
751
+ var ProposalSchema = z5.object({
752
+ repo: RepoIdSchema,
753
+ path: ProposablePathSchema,
754
+ branch: z5.string(),
755
+ baseSha: z5.string(),
756
+ commitSha: z5.string(),
757
+ pullRequestNumber: z5.number().int().positive(),
758
+ pullRequestUrl: z5.string().url(),
759
+ /** false when an open pull request was updated instead of created. */
760
+ created: z5.boolean()
761
+ });
762
+ var DEFAULT_WRITE_RATE_LIMIT = 20;
627
763
  var RepoInfoSchema = z5.object({
628
764
  id: RepoIdSchema,
629
765
  /** null when the manifest declares none — the framework's own repo does not. */
@@ -669,9 +805,19 @@ var McpErrorCodeSchema = z5.enum([
669
805
  "FILE_NOT_TEXT",
670
806
  "PATH_OUTSIDE_REPO",
671
807
  "UNAUTHORIZED",
672
- "TOKEN_MISSING"
808
+ "TOKEN_MISSING",
809
+ // v2 — write by proposal (B10, D17)
810
+ "GITHUB_TOKEN_MISSING",
811
+ "WRITE_NOT_CONFIGURED",
812
+ "WRITE_PATH_FORBIDDEN",
813
+ "SPEC_INVALID",
814
+ "SPEC_STATUS_FORBIDDEN",
815
+ "DECISION_INVALID",
816
+ "UNKNOWN_DECISION_REFERENCE",
817
+ "REMOTE_CONFLICT",
818
+ "WRITE_RATE_LIMITED",
819
+ "GITHUB_UNAVAILABLE"
673
820
  ]);
674
- var MAX_FILE_BYTES = 2e5;
675
821
  var MIN_TOKEN_LENGTH = 32;
676
822
 
677
823
  // src/registry.ts
@@ -743,7 +889,8 @@ async function openRepos(registry, deps) {
743
889
  id: declared.id,
744
890
  rootPath: declared.rootPath,
745
891
  adapter,
746
- ogygieVersion: null
892
+ ogygieVersion: null,
893
+ ...declared.github === void 0 ? {} : { github: declared.github }
747
894
  };
748
895
  const manifest = await readManifest(repo);
749
896
  repo.ogygieVersion = manifest.ogygieVersion ?? null;
@@ -778,6 +925,7 @@ function repoOf(repos, repoId) {
778
925
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
779
926
 
780
927
  // ../ogygie-core/src/decision-refs.ts
928
+ var DECISION_REFERENCE = /(?<![A-Za-z0-9])D(\d+)(?![A-Za-z0-9])/g;
781
929
  var DECISION_HEADING = /^##\s+D(\d+)\b/;
782
930
  function parseKnownDecisionIds(decisionsMarkdown) {
783
931
  const ids = [];
@@ -805,6 +953,28 @@ function extractDecisionSection(decisionsMarkdown, decisionId) {
805
953
  }
806
954
  return start === -1 ? null : trimTrailingBlanks(lines.slice(start));
807
955
  }
956
+ function findDecisionReferences(text) {
957
+ const references = [];
958
+ const lines = text.split("\n");
959
+ for (let index = 0; index < lines.length; index += 1) {
960
+ const line = lines[index] ?? "";
961
+ for (const match of line.matchAll(DECISION_REFERENCE)) {
962
+ references.push({ decision: `D${match[1]}`, line: index + 1 });
963
+ }
964
+ }
965
+ return references;
966
+ }
967
+ function findUnknownDecisionReferences(text, knownDecisionIds) {
968
+ const known = new Set(knownDecisionIds);
969
+ const unknown = [];
970
+ const alreadyReported = /* @__PURE__ */ new Set();
971
+ for (const reference of findDecisionReferences(text)) {
972
+ if (known.has(reference.decision) || alreadyReported.has(reference.decision)) continue;
973
+ alreadyReported.add(reference.decision);
974
+ unknown.push(reference);
975
+ }
976
+ return unknown;
977
+ }
808
978
 
809
979
  // ../ogygie-core/src/impact.ts
810
980
  import { z as z6 } from "zod";
@@ -1359,6 +1529,228 @@ async function readFeature(repo, input) {
1359
1529
  };
1360
1530
  }
1361
1531
 
1532
+ // src/proposals.ts
1533
+ var HOUR_MS = 36e5;
1534
+ var DECISION_HEADING2 = /^## D(\d+) — \d{4}-\d{2}-\d{2} — \S/;
1535
+ var DECISION_BULLETS = ["**D\xE9cision**", "**Motivation**", "**Exclut**"];
1536
+ function specIdOfPath(path) {
1537
+ return path.replace(/^specs\//, "").replace(/\.spec\.md$/, "");
1538
+ }
1539
+ function validateSpec(path, content) {
1540
+ const parsed = parseSpecFile(content);
1541
+ const problems = [...parsed.problems];
1542
+ if (parsed.frontmatter !== null && parsed.frontmatter.id !== specIdOfPath(path)) {
1543
+ problems.push(`id "${parsed.frontmatter.id}" differs from the file name "${specIdOfPath(path)}"`);
1544
+ }
1545
+ let cursor = 0;
1546
+ for (const section of SECTIONS_SPEC_OBLIGATOIRES) {
1547
+ const position = parsed.body.indexOf(`
1548
+ ${section}
1549
+ `, cursor);
1550
+ if (position === -1) problems.push(`section "${section}" missing or out of order`);
1551
+ else cursor = position + section.length;
1552
+ }
1553
+ if (problems.length > 0) {
1554
+ throwOgygieError({
1555
+ code: "SPEC_INVALID",
1556
+ message: `The proposed spec would fail verify-specs (${problems.length} problem(s)).`,
1557
+ file: path,
1558
+ cause: problems.join(" ; "),
1559
+ suggestedFix: "Fix the frontmatter (id, status, depends_on, priority, refs) and keep the six canonical sections in order."
1560
+ });
1561
+ }
1562
+ if (parsed.frontmatter?.status === "implemented") {
1563
+ throwOgygieError({
1564
+ code: "SPEC_STATUS_FORBIDDEN",
1565
+ message: "A proposal may not declare a spec implemented.",
1566
+ file: path,
1567
+ cause: "status=implemented",
1568
+ suggestedFix: "Propose the spec as draft or validated \u2014 the repo's manifest and CI say when it is implemented."
1569
+ });
1570
+ }
1571
+ }
1572
+ function validateDecisions(path, content, baseSame, baseOther) {
1573
+ const problems = [];
1574
+ for (const line of content.split("\n")) {
1575
+ if (line.startsWith("## ") && !DECISION_HEADING2.test(line)) {
1576
+ problems.push(`heading "${line}" is not "## D<n> \u2014 AAAA-MM-JJ \u2014 <titre>"`);
1577
+ }
1578
+ }
1579
+ const proposedIds = parseKnownDecisionIds(content);
1580
+ const otherIds = parseKnownDecisionIds(baseOther ?? "");
1581
+ const previousIds = parseKnownDecisionIds(baseSame ?? "");
1582
+ for (const id of previousIds) {
1583
+ if (!proposedIds.includes(id)) problems.push(`${id} was declared and is removed or renumbered`);
1584
+ }
1585
+ const all = [...proposedIds, ...otherIds];
1586
+ const seen = /* @__PURE__ */ new Set();
1587
+ for (const id of all) {
1588
+ if (seen.has(id)) problems.push(`${id} is declared twice across the decision files`);
1589
+ seen.add(id);
1590
+ }
1591
+ const numbers = [...seen].map((id) => Number.parseInt(id.slice(1), 10)).sort((a, b) => a - b);
1592
+ numbers.forEach((value, index) => {
1593
+ if (value !== index + 1) problems.push(`numbering has a gap: expected D${index + 1}, found D${value}`);
1594
+ });
1595
+ for (const id of proposedIds.filter((candidate) => !previousIds.includes(candidate))) {
1596
+ const section = extractDecisionSection(content, id) ?? "";
1597
+ for (const bullet of DECISION_BULLETS) {
1598
+ if (!section.includes(bullet)) problems.push(`${id} lacks its ${bullet} bullet`);
1599
+ }
1600
+ }
1601
+ if (problems.length > 0) {
1602
+ throwOgygieError({
1603
+ code: "DECISION_INVALID",
1604
+ message: `The proposed decisions file breaks the D<n> contract (${problems.length} problem(s)).`,
1605
+ file: path,
1606
+ cause: problems.join(" ; "),
1607
+ 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."
1608
+ });
1609
+ }
1610
+ }
1611
+ function checkDecisionReferences(path, content, known) {
1612
+ const unknown = findUnknownDecisionReferences(content, known);
1613
+ if (unknown.length > 0) {
1614
+ throwOgygieError({
1615
+ code: "UNKNOWN_DECISION_REFERENCE",
1616
+ message: `The proposal cites ${unknown.length} decision(s) the repo does not declare.`,
1617
+ file: path,
1618
+ cause: unknown.map((reference) => `${reference.decision} (line ${reference.line})`).join(", "),
1619
+ suggestedFix: `Cite only declared decisions (${known.join(", ") || "<none>"}), or propose the decision first.`
1620
+ });
1621
+ }
1622
+ }
1623
+ function branchSlug(path) {
1624
+ return path.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/(^-|-$)/g, "");
1625
+ }
1626
+ async function withGithub(action) {
1627
+ try {
1628
+ return await action();
1629
+ } catch (thrown) {
1630
+ if (thrown instanceof OgygieFailure) throw thrown;
1631
+ return throwOgygieError({
1632
+ code: "GITHUB_UNAVAILABLE",
1633
+ message: "The GitHub client failed outside its contract.",
1634
+ file: null,
1635
+ cause: thrown instanceof Error ? thrown.message : String(thrown),
1636
+ suggestedFix: "Retry \u2014 nothing was written, nothing is queued."
1637
+ });
1638
+ }
1639
+ }
1640
+ async function proposeChange(repo, deps, input) {
1641
+ const path = ProposablePathSchema.safeParse(input.path);
1642
+ if (!path.success) {
1643
+ throwOgygieError({
1644
+ code: "WRITE_PATH_FORBIDDEN",
1645
+ message: `"${input.path}" is outside what a proposal may touch.`,
1646
+ file: input.path,
1647
+ cause: "the perimeter is specs/<id>.spec.md (not the template), DECISIONS.md, DECISIONS.product.md",
1648
+ suggestedFix: "Propose a spec or a decisions file; code, manifest, governed files and FAILURES.md are out of reach (D17)."
1649
+ });
1650
+ }
1651
+ const github = repo.github;
1652
+ if (github === void 0) {
1653
+ return throwOgygieError({
1654
+ code: "WRITE_NOT_CONFIGURED",
1655
+ message: `Repo "${repo.id}" declares no GitHub coordinates: it is read-only.`,
1656
+ file: null,
1657
+ cause: "registry entry without a `github` block",
1658
+ suggestedFix: 'Add `"github": { "owner": "\u2026", "repo": "\u2026", "defaultBranch": "main" }` to that repo in the registry.'
1659
+ });
1660
+ }
1661
+ const now = deps.now();
1662
+ const recent = (deps.recentProposals.get(repo.id) ?? []).filter((at) => now - at < HOUR_MS);
1663
+ if (recent.length >= deps.rateLimitPerHour) {
1664
+ const retryAfterSeconds = Math.ceil(((recent[0] ?? now) + HOUR_MS - now) / 1e3);
1665
+ throwOgygieError({
1666
+ code: "WRITE_RATE_LIMITED",
1667
+ message: `Repo "${repo.id}" already received ${recent.length} proposals this hour.`,
1668
+ file: null,
1669
+ cause: `limit=${deps.rateLimitPerHour}/hour`,
1670
+ suggestedFix: `Retry in ${retryAfterSeconds} s, or merge or close the open proposals first.`
1671
+ });
1672
+ }
1673
+ const client = deps.clientFor(github);
1674
+ const baseSha = await withGithub(() => client.getBranchHead(github.defaultBranch));
1675
+ if (baseSha === null) {
1676
+ return throwOgygieError({
1677
+ code: "GITHUB_UNAVAILABLE",
1678
+ message: `Branch "${github.defaultBranch}" does not exist on ${github.owner}/${github.repo}.`,
1679
+ file: null,
1680
+ cause: "default branch head not found",
1681
+ suggestedFix: "Fix `defaultBranch` in the registry entry of that repo."
1682
+ });
1683
+ }
1684
+ const baseFiles = /* @__PURE__ */ new Map();
1685
+ for (const decisionsPath of DECISION_FILES) {
1686
+ const file = await withGithub(() => client.readFileAtRef(decisionsPath, baseSha));
1687
+ baseFiles.set(decisionsPath, file?.content ?? null);
1688
+ }
1689
+ if (input.path.startsWith("specs/")) {
1690
+ validateSpec(input.path, input.content);
1691
+ const known = DECISION_FILES.flatMap((file) => parseKnownDecisionIds(baseFiles.get(file) ?? ""));
1692
+ checkDecisionReferences(input.path, input.content, known);
1693
+ } else {
1694
+ const other = DECISION_FILES.find((file) => file !== input.path) ?? DECISION_FILES[1];
1695
+ validateDecisions(input.path, input.content, baseFiles.get(input.path) ?? null, baseFiles.get(other) ?? null);
1696
+ const known = [...parseKnownDecisionIds(input.content), ...parseKnownDecisionIds(baseFiles.get(other) ?? "")];
1697
+ checkDecisionReferences(input.path, input.content, known);
1698
+ }
1699
+ const headFile = await withGithub(() => client.readFileAtRef(input.path, baseSha));
1700
+ const local = await repo.adapter.exists(input.path) ? await repo.adapter.readFile(input.path) : null;
1701
+ if ((headFile?.content ?? null) !== local) {
1702
+ throwOgygieError({
1703
+ code: "REMOTE_CONFLICT",
1704
+ message: `"${input.path}" changed on ${github.defaultBranch} since the served clone was read.`,
1705
+ file: input.path,
1706
+ cause: `head sha=${headFile?.sha ?? "<absent>"}, served clone ${local === null ? "has no such file" : "differs"}`,
1707
+ suggestedFix: "Re-read the file (details.headContent is the current head), reapply your change and propose again.",
1708
+ details: { headSha: headFile?.sha ?? null, headContent: headFile?.content ?? null }
1709
+ });
1710
+ }
1711
+ let branch = `mcp/${repo.id}/${branchSlug(input.path)}`;
1712
+ let branchHead = await withGithub(() => client.getBranchHead(branch));
1713
+ let openPull = branchHead === null ? null : await withGithub(() => client.findOpenPullRequest(branch));
1714
+ if (branchHead !== null && openPull === null) {
1715
+ branch = `${branch}-${baseSha.slice(0, 7)}`;
1716
+ branchHead = await withGithub(() => client.getBranchHead(branch));
1717
+ openPull = branchHead === null ? null : await withGithub(() => client.findOpenPullRequest(branch));
1718
+ }
1719
+ recent.push(now);
1720
+ deps.recentProposals.set(repo.id, recent);
1721
+ let expectedSha = headFile?.sha ?? null;
1722
+ if (branchHead === null) {
1723
+ await withGithub(() => client.createBranch(branch, baseSha));
1724
+ } else {
1725
+ const onBranch = await withGithub(() => client.readFileAtRef(input.path, branchHead ?? baseSha));
1726
+ expectedSha = onBranch?.sha ?? null;
1727
+ }
1728
+ const commit = await withGithub(
1729
+ () => client.commitFile({ branch, path: input.path, content: input.content, message: input.message, expectedSha })
1730
+ );
1731
+ const pull = openPull ?? await withGithub(
1732
+ () => client.openPullRequest({
1733
+ branch,
1734
+ base: github.defaultBranch,
1735
+ title: (input.message.split("\n")[0] ?? input.message).slice(0, 72),
1736
+ body: `${input.message}
1737
+
1738
+ ---
1739
+ Propos\xE9 via @ogygie/mcp par ${deps.clientName()} (repo \`${repo.id}\`, chemin \`${input.path}\`).`
1740
+ })
1741
+ );
1742
+ return {
1743
+ repo: repo.id,
1744
+ path: path.data,
1745
+ branch,
1746
+ baseSha,
1747
+ commitSha: commit.commitSha,
1748
+ pullRequestNumber: pull.number,
1749
+ pullRequestUrl: pull.url,
1750
+ created: openPull === null
1751
+ };
1752
+ }
1753
+
1362
1754
  // src/server.ts
1363
1755
  function asText(payload) {
1364
1756
  return { content: [{ type: "text", text: JSON.stringify(payload, null, 2) }] };
@@ -1385,26 +1777,25 @@ function createOgygieMcpServer(deps) {
1385
1777
  { description, inputSchema },
1386
1778
  (async (input) => {
1387
1779
  const startedAt = Date.now();
1388
- try {
1389
- const result = await run(input);
1780
+ const audit = (outcome, result) => {
1781
+ const prUrl = result?.pullRequestUrl;
1390
1782
  deps.log?.(
1391
1783
  JSON.stringify({
1392
1784
  tool: name,
1393
1785
  repo: input["repo"] ?? null,
1394
- outcome: "ok",
1786
+ ...typeof input["path"] === "string" ? { path: input["path"] } : {},
1787
+ outcome,
1788
+ ...typeof prUrl === "string" ? { prUrl } : {},
1395
1789
  durationMs: Date.now() - startedAt
1396
1790
  })
1397
1791
  );
1792
+ };
1793
+ try {
1794
+ const result = await run(input);
1795
+ audit("ok", result);
1398
1796
  return asText(result);
1399
1797
  } catch (thrown) {
1400
- deps.log?.(
1401
- JSON.stringify({
1402
- tool: name,
1403
- repo: input["repo"] ?? null,
1404
- outcome: thrown instanceof OgygieFailure ? thrown.error.code : "UNEXPECTED",
1405
- durationMs: Date.now() - startedAt
1406
- })
1407
- );
1798
+ audit(thrown instanceof OgygieFailure ? thrown.error.code : "UNEXPECTED");
1408
1799
  return asError(thrown);
1409
1800
  }
1410
1801
  })
@@ -1458,6 +1849,23 @@ function createOgygieMcpServer(deps) {
1458
1849
  ReadFeatureInput,
1459
1850
  async (input) => readFeature(repoOf(deps.repos, input.repo), input)
1460
1851
  );
1852
+ const writable = deps.repos.some((repo) => repo.github !== void 0);
1853
+ if (deps.write !== void 0 && writable) {
1854
+ const write = deps.write;
1855
+ register(
1856
+ "ogygie_propose_change",
1857
+ "Proposes a spec (specs/<id>.spec.md) or a decisions file (DECISIONS.md, DECISIONS.product.md) as a commit on a mcp/ branch plus a pull request \u2014 never a file on the host. Validated like verify-specs first; refuses status implemented.",
1858
+ ProposeChangeInput,
1859
+ async (input) => proposeChange(
1860
+ repoOf(deps.repos, input.repo),
1861
+ {
1862
+ ...write,
1863
+ clientName: () => server.server.getClientVersion()?.name ?? "unknown-client"
1864
+ },
1865
+ input
1866
+ )
1867
+ );
1868
+ }
1461
1869
  return server;
1462
1870
  }
1463
1871
 
@@ -1573,13 +1981,21 @@ async function startHttpTransport(createServerInstance, options) {
1573
1981
 
1574
1982
  // src/main.ts
1575
1983
  function parseArguments(argv) {
1576
- const options = { configPath: null, http: false, port: 3010, host: void 0 };
1984
+ const options = {
1985
+ configPath: null,
1986
+ http: false,
1987
+ port: 3010,
1988
+ host: void 0,
1989
+ writeRateLimit: DEFAULT_WRITE_RATE_LIMIT
1990
+ };
1577
1991
  for (let index = 0; index < argv.length; index += 1) {
1578
1992
  const argument = argv[index];
1579
1993
  if (argument === "--config") options.configPath = argv[++index] ?? null;
1580
1994
  else if (argument === "--http") options.http = true;
1581
1995
  else if (argument === "--port") options.port = Number.parseInt(argv[++index] ?? "", 10);
1582
1996
  else if (argument === "--host") options.host = argv[++index];
1997
+ else if (argument === "--write-rate-limit")
1998
+ options.writeRateLimit = Number.parseInt(argv[++index] ?? "", 10) || DEFAULT_WRITE_RATE_LIMIT;
1583
1999
  }
1584
2000
  return options;
1585
2001
  }
@@ -1624,11 +2040,29 @@ async function startOgygieMcp(options) {
1624
2040
  openRepo: (rootPath) => createFilesystemRepoAdapter(resolve2(options.cwd, rootPath)),
1625
2041
  serverVersion: options.version
1626
2042
  });
2043
+ const writableRepos = repos.filter((repo) => repo.github !== void 0);
2044
+ if (writableRepos.length > 0 && options.githubToken === void 0) {
2045
+ throwOgygieError({
2046
+ code: "GITHUB_TOKEN_MISSING",
2047
+ message: `${writableRepos.length} repo(s) declare GitHub coordinates but OGYGIE_MCP_GITHUB_TOKEN is absent.`,
2048
+ file: null,
2049
+ cause: `writable repos: ${writableRepos.map((repo) => repo.id).join(", ")}`,
2050
+ suggestedFix: "Set OGYGIE_MCP_GITHUB_TOKEN (a token scoped to contents + pull requests on those repos), or remove the `github` blocks to serve read-only."
2051
+ });
2052
+ }
2053
+ const githubToken = options.githubToken;
2054
+ const write = githubToken === void 0 ? void 0 : {
2055
+ clientFor: (github) => createGithubWriteClient({ token: githubToken, fetchImpl: fetch }, github),
2056
+ now: () => Date.now(),
2057
+ rateLimitPerHour: parsed.writeRateLimit,
2058
+ recentProposals: /* @__PURE__ */ new Map()
2059
+ };
1627
2060
  const buildServer = () => createOgygieMcpServer({
1628
2061
  repos,
1629
2062
  version: options.version,
1630
2063
  log: (line) => process.stderr.write(`${line}
1631
- `)
2064
+ `),
2065
+ ...write === void 0 ? {} : { write }
1632
2066
  });
1633
2067
  if (!parsed.http) {
1634
2068
  const server = buildServer();
@@ -1641,7 +2075,7 @@ async function startOgygieMcp(options) {
1641
2075
  token: options.token
1642
2076
  });
1643
2077
  process.stderr.write(
1644
- `ogygie-mcp \u2014 HTTP sur ${parsed.host ?? "127.0.0.1"}:${running.port}, ${repos.length} repo(s).
2078
+ `ogygie-mcp \u2014 HTTP sur ${parsed.host ?? "127.0.0.1"}:${running.port}, ${repos.length} repo(s), ${writableRepos.length} accepting proposals.
1645
2079
  `
1646
2080
  );
1647
2081
  return { close: () => running.close() };
@@ -1660,6 +2094,7 @@ try {
1660
2094
  cwd: process.cwd(),
1661
2095
  version: ownVersion(),
1662
2096
  token: process.env["OGYGIE_MCP_TOKEN"],
2097
+ githubToken: process.env["OGYGIE_MCP_GITHUB_TOKEN"],
1663
2098
  // The one disk read that does not go through a RepoAdapter, injected from
1664
2099
  // here so no serving module has to import `node:fs` (B5).
1665
2100
  readConfigFile: (absolutePath) => readFileSync(absolutePath, "utf8")
package/dist/server.js CHANGED
@@ -199,6 +199,104 @@ function createFilesystemRepoAdapter(rootDir) {
199
199
  };
200
200
  }
201
201
 
202
+ // src/github-client.ts
203
+ function createGithubWriteClient(deps, github) {
204
+ const apiBase = deps.apiBase ?? "https://api.github.com";
205
+ const repoUrl = `${apiBase}/repos/${github.owner}/${github.repo}`;
206
+ async function request(method, url, body, allowNotFound) {
207
+ let response;
208
+ try {
209
+ response = await deps.fetchImpl(url, {
210
+ method,
211
+ headers: {
212
+ authorization: `Bearer ${deps.token}`,
213
+ accept: "application/vnd.github+json",
214
+ "x-github-api-version": "2022-11-28",
215
+ "user-agent": "ogygie-mcp",
216
+ ...body === void 0 ? {} : { "content-type": "application/json" }
217
+ },
218
+ ...body === void 0 ? {} : { body: JSON.stringify(body) }
219
+ });
220
+ } catch (thrown) {
221
+ return throwOgygieError({
222
+ code: "GITHUB_UNAVAILABLE",
223
+ message: `GitHub could not be reached for ${github.owner}/${github.repo}.`,
224
+ file: null,
225
+ cause: thrown instanceof Error ? thrown.message : String(thrown),
226
+ suggestedFix: "Retry once the network is back \u2014 nothing was written, nothing is queued."
227
+ });
228
+ }
229
+ if (response.status === 404 && allowNotFound) return null;
230
+ if (!response.ok) {
231
+ return throwOgygieError({
232
+ code: "GITHUB_UNAVAILABLE",
233
+ message: `GitHub answered ${response.status} for ${method} ${url.replace(apiBase, "")}.`,
234
+ file: null,
235
+ cause: `status=${response.status}`,
236
+ suggestedFix: response.status === 401 || response.status === 403 ? "Check OGYGIE_MCP_GITHUB_TOKEN: it must grant contents and pull requests on this repo." : "Retry later \u2014 nothing was written, nothing is queued."
237
+ });
238
+ }
239
+ return await response.json();
240
+ }
241
+ return {
242
+ async getBranchHead(branch) {
243
+ const ref = await request(
244
+ "GET",
245
+ `${repoUrl}/git/ref/heads/${encodeURIComponent(branch)}`,
246
+ void 0,
247
+ true
248
+ );
249
+ return ref?.object.sha ?? null;
250
+ },
251
+ async readFileAtRef(path, ref) {
252
+ const file = await request(
253
+ "GET",
254
+ `${repoUrl}/contents/${path}?ref=${encodeURIComponent(ref)}`,
255
+ void 0,
256
+ true
257
+ );
258
+ if (file === null) return null;
259
+ return { content: Buffer.from(file.content, "base64").toString("utf8"), sha: file.sha };
260
+ },
261
+ async createBranch(branch, fromSha) {
262
+ await request("POST", `${repoUrl}/git/refs`, { ref: `refs/heads/${branch}`, sha: fromSha }, false);
263
+ },
264
+ async commitFile(input) {
265
+ const result = await request(
266
+ "PUT",
267
+ `${repoUrl}/contents/${input.path}`,
268
+ {
269
+ message: input.message,
270
+ content: Buffer.from(input.content, "utf8").toString("base64"),
271
+ branch: input.branch,
272
+ ...input.expectedSha === null ? {} : { sha: input.expectedSha }
273
+ },
274
+ false
275
+ );
276
+ return { commitSha: result?.commit.sha ?? "" };
277
+ },
278
+ async findOpenPullRequest(branch) {
279
+ const pulls = await request(
280
+ "GET",
281
+ `${repoUrl}/pulls?state=open&head=${encodeURIComponent(`${github.owner}:${branch}`)}`,
282
+ void 0,
283
+ false
284
+ );
285
+ const first = pulls?.[0];
286
+ return first === void 0 ? null : { number: first.number, url: first.html_url };
287
+ },
288
+ async openPullRequest(input) {
289
+ const pull = await request(
290
+ "POST",
291
+ `${repoUrl}/pulls`,
292
+ { title: input.title, body: input.body, head: input.branch, base: input.base },
293
+ false
294
+ );
295
+ return { number: pull?.number ?? 0, url: pull?.html_url ?? "" };
296
+ }
297
+ };
298
+ }
299
+
202
300
  // ../ogygie-core/src/manifest.schema.ts
203
301
  import { z as z3 } from "zod";
204
302
  var FeatureNameSchema = z3.string().regex(/^[a-z][a-z0-9]*(-[a-z0-9]+)*$/, "kebab-case required");
@@ -496,6 +594,14 @@ var SpecFrontmatterSchema = z4.object({
496
594
  refs: z4.array(z4.string()).default([]).describe("References to the domain model, e.g. 'modele-gally \xA74.2'"),
497
595
  deferred: z4.array(DeferredBehaviorSchema).default([]).describe("Behaviors deferred to a later feature, blocked by name")
498
596
  });
597
+ var SECTIONS_SPEC_OBLIGATOIRES = [
598
+ "# @intent",
599
+ "# Comportements",
600
+ "# Contrats de donn\xE9es",
601
+ "# \xC9tats UI",
602
+ "# Non-objectifs",
603
+ "# Budget"
604
+ ];
499
605
  function splitInlineArrayElements(inner) {
500
606
  const elements = [];
501
607
  let current = "";
@@ -593,12 +699,19 @@ var GovernanceFileSchema = z5.enum([
593
699
  "AGENTS.product.md",
594
700
  "FAILURES.md"
595
701
  ]);
702
+ var MAX_FILE_BYTES = 2e5;
596
703
  var DECISION_FILES = ["DECISIONS.md", "DECISIONS.product.md"];
704
+ var GithubCoordinatesSchema = z5.object({
705
+ owner: z5.string().min(1),
706
+ repo: z5.string().min(1),
707
+ defaultBranch: z5.string().min(1).default("main")
708
+ });
597
709
  var RepoRegistrySchema = z5.object({
598
710
  repos: z5.array(
599
711
  z5.object({
600
712
  id: RepoIdSchema,
601
- rootPath: z5.string().min(1)
713
+ rootPath: z5.string().min(1),
714
+ github: GithubCoordinatesSchema.optional()
602
715
  })
603
716
  ).min(1)
604
717
  });
@@ -619,6 +732,29 @@ var ReadFeatureInput = {
619
732
  feature: FeatureNameSchema2,
620
733
  file: z5.string().optional()
621
734
  };
735
+ var ProposablePathSchema = z5.union([
736
+ z5.string().regex(/^specs\/(?!_)[a-z][a-z0-9]*(-[a-z0-9]+)*\.spec\.md$/),
737
+ z5.literal("DECISIONS.md"),
738
+ z5.literal("DECISIONS.product.md")
739
+ ]);
740
+ var ProposeChangeInput = {
741
+ repo: RepoIdSchema,
742
+ path: z5.string(),
743
+ content: z5.string().min(1).max(MAX_FILE_BYTES),
744
+ message: z5.string().min(10).max(2e3)
745
+ };
746
+ var ProposalSchema = z5.object({
747
+ repo: RepoIdSchema,
748
+ path: ProposablePathSchema,
749
+ branch: z5.string(),
750
+ baseSha: z5.string(),
751
+ commitSha: z5.string(),
752
+ pullRequestNumber: z5.number().int().positive(),
753
+ pullRequestUrl: z5.string().url(),
754
+ /** false when an open pull request was updated instead of created. */
755
+ created: z5.boolean()
756
+ });
757
+ var DEFAULT_WRITE_RATE_LIMIT = 20;
622
758
  var RepoInfoSchema = z5.object({
623
759
  id: RepoIdSchema,
624
760
  /** null when the manifest declares none — the framework's own repo does not. */
@@ -664,9 +800,19 @@ var McpErrorCodeSchema = z5.enum([
664
800
  "FILE_NOT_TEXT",
665
801
  "PATH_OUTSIDE_REPO",
666
802
  "UNAUTHORIZED",
667
- "TOKEN_MISSING"
803
+ "TOKEN_MISSING",
804
+ // v2 — write by proposal (B10, D17)
805
+ "GITHUB_TOKEN_MISSING",
806
+ "WRITE_NOT_CONFIGURED",
807
+ "WRITE_PATH_FORBIDDEN",
808
+ "SPEC_INVALID",
809
+ "SPEC_STATUS_FORBIDDEN",
810
+ "DECISION_INVALID",
811
+ "UNKNOWN_DECISION_REFERENCE",
812
+ "REMOTE_CONFLICT",
813
+ "WRITE_RATE_LIMITED",
814
+ "GITHUB_UNAVAILABLE"
668
815
  ]);
669
- var MAX_FILE_BYTES = 2e5;
670
816
  var MIN_TOKEN_LENGTH = 32;
671
817
 
672
818
  // src/registry.ts
@@ -738,7 +884,8 @@ async function openRepos(registry, deps) {
738
884
  id: declared.id,
739
885
  rootPath: declared.rootPath,
740
886
  adapter,
741
- ogygieVersion: null
887
+ ogygieVersion: null,
888
+ ...declared.github === void 0 ? {} : { github: declared.github }
742
889
  };
743
890
  const manifest = await readManifest(repo);
744
891
  repo.ogygieVersion = manifest.ogygieVersion ?? null;
@@ -773,6 +920,7 @@ function repoOf(repos, repoId) {
773
920
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
774
921
 
775
922
  // ../ogygie-core/src/decision-refs.ts
923
+ var DECISION_REFERENCE = /(?<![A-Za-z0-9])D(\d+)(?![A-Za-z0-9])/g;
776
924
  var DECISION_HEADING = /^##\s+D(\d+)\b/;
777
925
  function parseKnownDecisionIds(decisionsMarkdown) {
778
926
  const ids = [];
@@ -800,6 +948,28 @@ function extractDecisionSection(decisionsMarkdown, decisionId) {
800
948
  }
801
949
  return start === -1 ? null : trimTrailingBlanks(lines.slice(start));
802
950
  }
951
+ function findDecisionReferences(text) {
952
+ const references = [];
953
+ const lines = text.split("\n");
954
+ for (let index = 0; index < lines.length; index += 1) {
955
+ const line = lines[index] ?? "";
956
+ for (const match of line.matchAll(DECISION_REFERENCE)) {
957
+ references.push({ decision: `D${match[1]}`, line: index + 1 });
958
+ }
959
+ }
960
+ return references;
961
+ }
962
+ function findUnknownDecisionReferences(text, knownDecisionIds) {
963
+ const known = new Set(knownDecisionIds);
964
+ const unknown = [];
965
+ const alreadyReported = /* @__PURE__ */ new Set();
966
+ for (const reference of findDecisionReferences(text)) {
967
+ if (known.has(reference.decision) || alreadyReported.has(reference.decision)) continue;
968
+ alreadyReported.add(reference.decision);
969
+ unknown.push(reference);
970
+ }
971
+ return unknown;
972
+ }
803
973
 
804
974
  // ../ogygie-core/src/impact.ts
805
975
  import { z as z6 } from "zod";
@@ -1354,6 +1524,228 @@ async function readFeature(repo, input) {
1354
1524
  };
1355
1525
  }
1356
1526
 
1527
+ // src/proposals.ts
1528
+ var HOUR_MS = 36e5;
1529
+ var DECISION_HEADING2 = /^## D(\d+) — \d{4}-\d{2}-\d{2} — \S/;
1530
+ var DECISION_BULLETS = ["**D\xE9cision**", "**Motivation**", "**Exclut**"];
1531
+ function specIdOfPath(path) {
1532
+ return path.replace(/^specs\//, "").replace(/\.spec\.md$/, "");
1533
+ }
1534
+ function validateSpec(path, content) {
1535
+ const parsed = parseSpecFile(content);
1536
+ const problems = [...parsed.problems];
1537
+ if (parsed.frontmatter !== null && parsed.frontmatter.id !== specIdOfPath(path)) {
1538
+ problems.push(`id "${parsed.frontmatter.id}" differs from the file name "${specIdOfPath(path)}"`);
1539
+ }
1540
+ let cursor = 0;
1541
+ for (const section of SECTIONS_SPEC_OBLIGATOIRES) {
1542
+ const position = parsed.body.indexOf(`
1543
+ ${section}
1544
+ `, cursor);
1545
+ if (position === -1) problems.push(`section "${section}" missing or out of order`);
1546
+ else cursor = position + section.length;
1547
+ }
1548
+ if (problems.length > 0) {
1549
+ throwOgygieError({
1550
+ code: "SPEC_INVALID",
1551
+ message: `The proposed spec would fail verify-specs (${problems.length} problem(s)).`,
1552
+ file: path,
1553
+ cause: problems.join(" ; "),
1554
+ suggestedFix: "Fix the frontmatter (id, status, depends_on, priority, refs) and keep the six canonical sections in order."
1555
+ });
1556
+ }
1557
+ if (parsed.frontmatter?.status === "implemented") {
1558
+ throwOgygieError({
1559
+ code: "SPEC_STATUS_FORBIDDEN",
1560
+ message: "A proposal may not declare a spec implemented.",
1561
+ file: path,
1562
+ cause: "status=implemented",
1563
+ suggestedFix: "Propose the spec as draft or validated \u2014 the repo's manifest and CI say when it is implemented."
1564
+ });
1565
+ }
1566
+ }
1567
+ function validateDecisions(path, content, baseSame, baseOther) {
1568
+ const problems = [];
1569
+ for (const line of content.split("\n")) {
1570
+ if (line.startsWith("## ") && !DECISION_HEADING2.test(line)) {
1571
+ problems.push(`heading "${line}" is not "## D<n> \u2014 AAAA-MM-JJ \u2014 <titre>"`);
1572
+ }
1573
+ }
1574
+ const proposedIds = parseKnownDecisionIds(content);
1575
+ const otherIds = parseKnownDecisionIds(baseOther ?? "");
1576
+ const previousIds = parseKnownDecisionIds(baseSame ?? "");
1577
+ for (const id of previousIds) {
1578
+ if (!proposedIds.includes(id)) problems.push(`${id} was declared and is removed or renumbered`);
1579
+ }
1580
+ const all = [...proposedIds, ...otherIds];
1581
+ const seen = /* @__PURE__ */ new Set();
1582
+ for (const id of all) {
1583
+ if (seen.has(id)) problems.push(`${id} is declared twice across the decision files`);
1584
+ seen.add(id);
1585
+ }
1586
+ const numbers = [...seen].map((id) => Number.parseInt(id.slice(1), 10)).sort((a, b) => a - b);
1587
+ numbers.forEach((value, index) => {
1588
+ if (value !== index + 1) problems.push(`numbering has a gap: expected D${index + 1}, found D${value}`);
1589
+ });
1590
+ for (const id of proposedIds.filter((candidate) => !previousIds.includes(candidate))) {
1591
+ const section = extractDecisionSection(content, id) ?? "";
1592
+ for (const bullet of DECISION_BULLETS) {
1593
+ if (!section.includes(bullet)) problems.push(`${id} lacks its ${bullet} bullet`);
1594
+ }
1595
+ }
1596
+ if (problems.length > 0) {
1597
+ throwOgygieError({
1598
+ code: "DECISION_INVALID",
1599
+ message: `The proposed decisions file breaks the D<n> contract (${problems.length} problem(s)).`,
1600
+ file: path,
1601
+ cause: problems.join(" ; "),
1602
+ 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."
1603
+ });
1604
+ }
1605
+ }
1606
+ function checkDecisionReferences(path, content, known) {
1607
+ const unknown = findUnknownDecisionReferences(content, known);
1608
+ if (unknown.length > 0) {
1609
+ throwOgygieError({
1610
+ code: "UNKNOWN_DECISION_REFERENCE",
1611
+ message: `The proposal cites ${unknown.length} decision(s) the repo does not declare.`,
1612
+ file: path,
1613
+ cause: unknown.map((reference) => `${reference.decision} (line ${reference.line})`).join(", "),
1614
+ suggestedFix: `Cite only declared decisions (${known.join(", ") || "<none>"}), or propose the decision first.`
1615
+ });
1616
+ }
1617
+ }
1618
+ function branchSlug(path) {
1619
+ return path.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/(^-|-$)/g, "");
1620
+ }
1621
+ async function withGithub(action) {
1622
+ try {
1623
+ return await action();
1624
+ } catch (thrown) {
1625
+ if (thrown instanceof OgygieFailure) throw thrown;
1626
+ return throwOgygieError({
1627
+ code: "GITHUB_UNAVAILABLE",
1628
+ message: "The GitHub client failed outside its contract.",
1629
+ file: null,
1630
+ cause: thrown instanceof Error ? thrown.message : String(thrown),
1631
+ suggestedFix: "Retry \u2014 nothing was written, nothing is queued."
1632
+ });
1633
+ }
1634
+ }
1635
+ async function proposeChange(repo, deps, input) {
1636
+ const path = ProposablePathSchema.safeParse(input.path);
1637
+ if (!path.success) {
1638
+ throwOgygieError({
1639
+ code: "WRITE_PATH_FORBIDDEN",
1640
+ message: `"${input.path}" is outside what a proposal may touch.`,
1641
+ file: input.path,
1642
+ cause: "the perimeter is specs/<id>.spec.md (not the template), DECISIONS.md, DECISIONS.product.md",
1643
+ suggestedFix: "Propose a spec or a decisions file; code, manifest, governed files and FAILURES.md are out of reach (D17)."
1644
+ });
1645
+ }
1646
+ const github = repo.github;
1647
+ if (github === void 0) {
1648
+ return throwOgygieError({
1649
+ code: "WRITE_NOT_CONFIGURED",
1650
+ message: `Repo "${repo.id}" declares no GitHub coordinates: it is read-only.`,
1651
+ file: null,
1652
+ cause: "registry entry without a `github` block",
1653
+ suggestedFix: 'Add `"github": { "owner": "\u2026", "repo": "\u2026", "defaultBranch": "main" }` to that repo in the registry.'
1654
+ });
1655
+ }
1656
+ const now = deps.now();
1657
+ const recent = (deps.recentProposals.get(repo.id) ?? []).filter((at) => now - at < HOUR_MS);
1658
+ if (recent.length >= deps.rateLimitPerHour) {
1659
+ const retryAfterSeconds = Math.ceil(((recent[0] ?? now) + HOUR_MS - now) / 1e3);
1660
+ throwOgygieError({
1661
+ code: "WRITE_RATE_LIMITED",
1662
+ message: `Repo "${repo.id}" already received ${recent.length} proposals this hour.`,
1663
+ file: null,
1664
+ cause: `limit=${deps.rateLimitPerHour}/hour`,
1665
+ suggestedFix: `Retry in ${retryAfterSeconds} s, or merge or close the open proposals first.`
1666
+ });
1667
+ }
1668
+ const client = deps.clientFor(github);
1669
+ const baseSha = await withGithub(() => client.getBranchHead(github.defaultBranch));
1670
+ if (baseSha === null) {
1671
+ return throwOgygieError({
1672
+ code: "GITHUB_UNAVAILABLE",
1673
+ message: `Branch "${github.defaultBranch}" does not exist on ${github.owner}/${github.repo}.`,
1674
+ file: null,
1675
+ cause: "default branch head not found",
1676
+ suggestedFix: "Fix `defaultBranch` in the registry entry of that repo."
1677
+ });
1678
+ }
1679
+ const baseFiles = /* @__PURE__ */ new Map();
1680
+ for (const decisionsPath of DECISION_FILES) {
1681
+ const file = await withGithub(() => client.readFileAtRef(decisionsPath, baseSha));
1682
+ baseFiles.set(decisionsPath, file?.content ?? null);
1683
+ }
1684
+ if (input.path.startsWith("specs/")) {
1685
+ validateSpec(input.path, input.content);
1686
+ const known = DECISION_FILES.flatMap((file) => parseKnownDecisionIds(baseFiles.get(file) ?? ""));
1687
+ checkDecisionReferences(input.path, input.content, known);
1688
+ } else {
1689
+ const other = DECISION_FILES.find((file) => file !== input.path) ?? DECISION_FILES[1];
1690
+ validateDecisions(input.path, input.content, baseFiles.get(input.path) ?? null, baseFiles.get(other) ?? null);
1691
+ const known = [...parseKnownDecisionIds(input.content), ...parseKnownDecisionIds(baseFiles.get(other) ?? "")];
1692
+ checkDecisionReferences(input.path, input.content, known);
1693
+ }
1694
+ const headFile = await withGithub(() => client.readFileAtRef(input.path, baseSha));
1695
+ const local = await repo.adapter.exists(input.path) ? await repo.adapter.readFile(input.path) : null;
1696
+ if ((headFile?.content ?? null) !== local) {
1697
+ throwOgygieError({
1698
+ code: "REMOTE_CONFLICT",
1699
+ message: `"${input.path}" changed on ${github.defaultBranch} since the served clone was read.`,
1700
+ file: input.path,
1701
+ cause: `head sha=${headFile?.sha ?? "<absent>"}, served clone ${local === null ? "has no such file" : "differs"}`,
1702
+ suggestedFix: "Re-read the file (details.headContent is the current head), reapply your change and propose again.",
1703
+ details: { headSha: headFile?.sha ?? null, headContent: headFile?.content ?? null }
1704
+ });
1705
+ }
1706
+ let branch = `mcp/${repo.id}/${branchSlug(input.path)}`;
1707
+ let branchHead = await withGithub(() => client.getBranchHead(branch));
1708
+ let openPull = branchHead === null ? null : await withGithub(() => client.findOpenPullRequest(branch));
1709
+ if (branchHead !== null && openPull === null) {
1710
+ branch = `${branch}-${baseSha.slice(0, 7)}`;
1711
+ branchHead = await withGithub(() => client.getBranchHead(branch));
1712
+ openPull = branchHead === null ? null : await withGithub(() => client.findOpenPullRequest(branch));
1713
+ }
1714
+ recent.push(now);
1715
+ deps.recentProposals.set(repo.id, recent);
1716
+ let expectedSha = headFile?.sha ?? null;
1717
+ if (branchHead === null) {
1718
+ await withGithub(() => client.createBranch(branch, baseSha));
1719
+ } else {
1720
+ const onBranch = await withGithub(() => client.readFileAtRef(input.path, branchHead ?? baseSha));
1721
+ expectedSha = onBranch?.sha ?? null;
1722
+ }
1723
+ const commit = await withGithub(
1724
+ () => client.commitFile({ branch, path: input.path, content: input.content, message: input.message, expectedSha })
1725
+ );
1726
+ const pull = openPull ?? await withGithub(
1727
+ () => client.openPullRequest({
1728
+ branch,
1729
+ base: github.defaultBranch,
1730
+ title: (input.message.split("\n")[0] ?? input.message).slice(0, 72),
1731
+ body: `${input.message}
1732
+
1733
+ ---
1734
+ Propos\xE9 via @ogygie/mcp par ${deps.clientName()} (repo \`${repo.id}\`, chemin \`${input.path}\`).`
1735
+ })
1736
+ );
1737
+ return {
1738
+ repo: repo.id,
1739
+ path: path.data,
1740
+ branch,
1741
+ baseSha,
1742
+ commitSha: commit.commitSha,
1743
+ pullRequestNumber: pull.number,
1744
+ pullRequestUrl: pull.url,
1745
+ created: openPull === null
1746
+ };
1747
+ }
1748
+
1357
1749
  // src/server.ts
1358
1750
  function asText(payload) {
1359
1751
  return { content: [{ type: "text", text: JSON.stringify(payload, null, 2) }] };
@@ -1380,26 +1772,25 @@ function createOgygieMcpServer(deps) {
1380
1772
  { description, inputSchema },
1381
1773
  (async (input) => {
1382
1774
  const startedAt = Date.now();
1383
- try {
1384
- const result = await run(input);
1775
+ const audit = (outcome, result) => {
1776
+ const prUrl = result?.pullRequestUrl;
1385
1777
  deps.log?.(
1386
1778
  JSON.stringify({
1387
1779
  tool: name,
1388
1780
  repo: input["repo"] ?? null,
1389
- outcome: "ok",
1781
+ ...typeof input["path"] === "string" ? { path: input["path"] } : {},
1782
+ outcome,
1783
+ ...typeof prUrl === "string" ? { prUrl } : {},
1390
1784
  durationMs: Date.now() - startedAt
1391
1785
  })
1392
1786
  );
1787
+ };
1788
+ try {
1789
+ const result = await run(input);
1790
+ audit("ok", result);
1393
1791
  return asText(result);
1394
1792
  } catch (thrown) {
1395
- deps.log?.(
1396
- JSON.stringify({
1397
- tool: name,
1398
- repo: input["repo"] ?? null,
1399
- outcome: thrown instanceof OgygieFailure ? thrown.error.code : "UNEXPECTED",
1400
- durationMs: Date.now() - startedAt
1401
- })
1402
- );
1793
+ audit(thrown instanceof OgygieFailure ? thrown.error.code : "UNEXPECTED");
1403
1794
  return asError(thrown);
1404
1795
  }
1405
1796
  })
@@ -1453,6 +1844,23 @@ function createOgygieMcpServer(deps) {
1453
1844
  ReadFeatureInput,
1454
1845
  async (input) => readFeature(repoOf(deps.repos, input.repo), input)
1455
1846
  );
1847
+ const writable = deps.repos.some((repo) => repo.github !== void 0);
1848
+ if (deps.write !== void 0 && writable) {
1849
+ const write = deps.write;
1850
+ register(
1851
+ "ogygie_propose_change",
1852
+ "Proposes a spec (specs/<id>.spec.md) or a decisions file (DECISIONS.md, DECISIONS.product.md) as a commit on a mcp/ branch plus a pull request \u2014 never a file on the host. Validated like verify-specs first; refuses status implemented.",
1853
+ ProposeChangeInput,
1854
+ async (input) => proposeChange(
1855
+ repoOf(deps.repos, input.repo),
1856
+ {
1857
+ ...write,
1858
+ clientName: () => server.server.getClientVersion()?.name ?? "unknown-client"
1859
+ },
1860
+ input
1861
+ )
1862
+ );
1863
+ }
1456
1864
  return server;
1457
1865
  }
1458
1866
 
@@ -1568,13 +1976,21 @@ async function startHttpTransport(createServerInstance, options) {
1568
1976
 
1569
1977
  // src/main.ts
1570
1978
  function parseArguments(argv) {
1571
- const options = { configPath: null, http: false, port: 3010, host: void 0 };
1979
+ const options = {
1980
+ configPath: null,
1981
+ http: false,
1982
+ port: 3010,
1983
+ host: void 0,
1984
+ writeRateLimit: DEFAULT_WRITE_RATE_LIMIT
1985
+ };
1572
1986
  for (let index = 0; index < argv.length; index += 1) {
1573
1987
  const argument = argv[index];
1574
1988
  if (argument === "--config") options.configPath = argv[++index] ?? null;
1575
1989
  else if (argument === "--http") options.http = true;
1576
1990
  else if (argument === "--port") options.port = Number.parseInt(argv[++index] ?? "", 10);
1577
1991
  else if (argument === "--host") options.host = argv[++index];
1992
+ else if (argument === "--write-rate-limit")
1993
+ options.writeRateLimit = Number.parseInt(argv[++index] ?? "", 10) || DEFAULT_WRITE_RATE_LIMIT;
1578
1994
  }
1579
1995
  return options;
1580
1996
  }
@@ -1619,11 +2035,29 @@ async function startOgygieMcp(options) {
1619
2035
  openRepo: (rootPath) => createFilesystemRepoAdapter(resolve2(options.cwd, rootPath)),
1620
2036
  serverVersion: options.version
1621
2037
  });
2038
+ const writableRepos = repos.filter((repo) => repo.github !== void 0);
2039
+ if (writableRepos.length > 0 && options.githubToken === void 0) {
2040
+ throwOgygieError({
2041
+ code: "GITHUB_TOKEN_MISSING",
2042
+ message: `${writableRepos.length} repo(s) declare GitHub coordinates but OGYGIE_MCP_GITHUB_TOKEN is absent.`,
2043
+ file: null,
2044
+ cause: `writable repos: ${writableRepos.map((repo) => repo.id).join(", ")}`,
2045
+ suggestedFix: "Set OGYGIE_MCP_GITHUB_TOKEN (a token scoped to contents + pull requests on those repos), or remove the `github` blocks to serve read-only."
2046
+ });
2047
+ }
2048
+ const githubToken = options.githubToken;
2049
+ const write = githubToken === void 0 ? void 0 : {
2050
+ clientFor: (github) => createGithubWriteClient({ token: githubToken, fetchImpl: fetch }, github),
2051
+ now: () => Date.now(),
2052
+ rateLimitPerHour: parsed.writeRateLimit,
2053
+ recentProposals: /* @__PURE__ */ new Map()
2054
+ };
1622
2055
  const buildServer = () => createOgygieMcpServer({
1623
2056
  repos,
1624
2057
  version: options.version,
1625
2058
  log: (line) => process.stderr.write(`${line}
1626
- `)
2059
+ `),
2060
+ ...write === void 0 ? {} : { write }
1627
2061
  });
1628
2062
  if (!parsed.http) {
1629
2063
  const server = buildServer();
@@ -1636,7 +2070,7 @@ async function startOgygieMcp(options) {
1636
2070
  token: options.token
1637
2071
  });
1638
2072
  process.stderr.write(
1639
- `ogygie-mcp \u2014 HTTP sur ${parsed.host ?? "127.0.0.1"}:${running.port}, ${repos.length} repo(s).
2073
+ `ogygie-mcp \u2014 HTTP sur ${parsed.host ?? "127.0.0.1"}:${running.port}, ${repos.length} repo(s), ${writableRepos.length} accepting proposals.
1640
2074
  `
1641
2075
  );
1642
2076
  return { close: () => running.close() };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ogygie/mcp",
3
- "version": "0.12.0",
3
+ "version": "0.13.1",
4
4
  "description": "Ogygie MCP server: read-only access to the governance files and features of any Ogygie repo, the manifest as index. Ships built; runnable standalone or through `ogygie mcp`.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -23,7 +23,7 @@
23
23
  "zod": "^4.0.0"
24
24
  },
25
25
  "devDependencies": {
26
- "@ogygie/core": "0.12.0",
26
+ "@ogygie/core": "0.13.1",
27
27
  "@types/node": "^24.0.0",
28
28
  "esbuild": "^0.28.0",
29
29
  "tsx": "^4.20.0",