@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 +27 -3
- package/dist/mcp.js +453 -18
- package/dist/server.js +452 -18
- package/package.json +2 -2
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
|
|
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
|
-
|
|
1389
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
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 = {
|
|
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
|
-
|
|
1384
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
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 = {
|
|
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.
|
|
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.
|
|
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",
|