@ogygie/mcp 0.19.0 → 0.21.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/mcp.js +600 -561
- package/dist/server.js +600 -561
- package/package.json +7 -2
package/dist/server.js
CHANGED
|
@@ -199,104 +199,64 @@ function createFilesystemRepoAdapter(rootDir) {
|
|
|
199
199
|
};
|
|
200
200
|
}
|
|
201
201
|
|
|
202
|
-
// src/
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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
|
-
});
|
|
202
|
+
// ../ogygie-core/src/proposal.ts
|
|
203
|
+
import { z as z5 } from "zod";
|
|
204
|
+
|
|
205
|
+
// ../ogygie-core/src/decision-refs.ts
|
|
206
|
+
var DECISION_REFERENCE = /(?<![A-Za-z0-9])D(\d+)(?![A-Za-z0-9])/g;
|
|
207
|
+
var DECISION_HEADING = /^##\s+D(\d+)\b/;
|
|
208
|
+
function parseKnownDecisionIds(decisionsMarkdown) {
|
|
209
|
+
const ids = [];
|
|
210
|
+
for (const line of decisionsMarkdown.split("\n")) {
|
|
211
|
+
const heading = DECISION_HEADING.exec(line);
|
|
212
|
+
if (heading !== null) ids.push(`D${heading[1]}`);
|
|
213
|
+
}
|
|
214
|
+
return ids;
|
|
215
|
+
}
|
|
216
|
+
function extractDecisionSection(decisionsMarkdown, decisionId) {
|
|
217
|
+
const lines = decisionsMarkdown.split("\n");
|
|
218
|
+
const trimTrailingBlanks = (section) => {
|
|
219
|
+
while (section.length > 0 && (section[section.length - 1] ?? "").trim() === "") section.pop();
|
|
220
|
+
return section.join("\n");
|
|
221
|
+
};
|
|
222
|
+
let start = -1;
|
|
223
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
224
|
+
const heading = DECISION_HEADING.exec(lines[index] ?? "");
|
|
225
|
+
if (heading === null) continue;
|
|
226
|
+
if (start === -1) {
|
|
227
|
+
if (`D${heading[1]}` === decisionId) start = index;
|
|
228
|
+
continue;
|
|
238
229
|
}
|
|
239
|
-
return
|
|
230
|
+
return trimTrailingBlanks(lines.slice(start, index));
|
|
240
231
|
}
|
|
241
|
-
return
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
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 ?? "" };
|
|
232
|
+
return start === -1 ? null : trimTrailingBlanks(lines.slice(start));
|
|
233
|
+
}
|
|
234
|
+
function findDecisionReferences(text) {
|
|
235
|
+
const references = [];
|
|
236
|
+
const lines = text.split("\n");
|
|
237
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
238
|
+
const line = lines[index] ?? "";
|
|
239
|
+
for (const match of line.matchAll(DECISION_REFERENCE)) {
|
|
240
|
+
references.push({ decision: `D${match[1]}`, line: index + 1 });
|
|
296
241
|
}
|
|
297
|
-
}
|
|
242
|
+
}
|
|
243
|
+
return references;
|
|
244
|
+
}
|
|
245
|
+
function findUnknownDecisionReferences(text, knownDecisionIds) {
|
|
246
|
+
const known = new Set(knownDecisionIds);
|
|
247
|
+
const unknown = [];
|
|
248
|
+
const alreadyReported = /* @__PURE__ */ new Set();
|
|
249
|
+
for (const reference of findDecisionReferences(text)) {
|
|
250
|
+
if (known.has(reference.decision) || alreadyReported.has(reference.decision)) continue;
|
|
251
|
+
alreadyReported.add(reference.decision);
|
|
252
|
+
unknown.push(reference);
|
|
253
|
+
}
|
|
254
|
+
return unknown;
|
|
298
255
|
}
|
|
299
256
|
|
|
257
|
+
// ../ogygie-core/src/spec.schema.ts
|
|
258
|
+
import { z as z4 } from "zod";
|
|
259
|
+
|
|
300
260
|
// ../ogygie-core/src/manifest.schema.ts
|
|
301
261
|
import { z as z3 } from "zod";
|
|
302
262
|
var FeatureNameSchema = z3.string().regex(/^[a-z][a-z0-9]*(-[a-z0-9]+)*$/, "kebab-case required");
|
|
@@ -577,11 +537,7 @@ var OgygieManifestSchema = z3.object({
|
|
|
577
537
|
});
|
|
578
538
|
});
|
|
579
539
|
|
|
580
|
-
// src/schemas.ts
|
|
581
|
-
import { z as z5 } from "zod";
|
|
582
|
-
|
|
583
540
|
// ../ogygie-core/src/spec.schema.ts
|
|
584
|
-
import { z as z4 } from "zod";
|
|
585
541
|
var DeferredBehaviorSchema = z4.string().regex(
|
|
586
542
|
/^.+ blockedBy:[a-z][a-z0-9]*(-[a-z0-9]+)*$/,
|
|
587
543
|
'Expected "<behavior> blockedBy:<feature-name>" (feature name in kebab-case)'
|
|
@@ -687,152 +643,507 @@ function parseSpecFrontmatter(text) {
|
|
|
687
643
|
}
|
|
688
644
|
return { value, problems };
|
|
689
645
|
}
|
|
646
|
+
var FRONTMATTER_BLOCK = /^---\n([\s\S]*?)\n---/;
|
|
647
|
+
function parseSpecDocument(content) {
|
|
648
|
+
const match = FRONTMATTER_BLOCK.exec(content);
|
|
649
|
+
if (match === null || match[1] === void 0) {
|
|
650
|
+
return {
|
|
651
|
+
frontmatter: null,
|
|
652
|
+
problems: ["file does not start with a --- ... --- frontmatter block"],
|
|
653
|
+
body: content
|
|
654
|
+
};
|
|
655
|
+
}
|
|
656
|
+
const body = content.slice(match[0].length);
|
|
657
|
+
const parsed = parseSpecFrontmatter(match[1]);
|
|
658
|
+
if (parsed.problems.length > 0) {
|
|
659
|
+
return { frontmatter: null, problems: parsed.problems, body };
|
|
660
|
+
}
|
|
661
|
+
const checked = SpecFrontmatterSchema.safeParse(parsed.value);
|
|
662
|
+
if (!checked.success) {
|
|
663
|
+
return {
|
|
664
|
+
frontmatter: null,
|
|
665
|
+
problems: checked.error.issues.map((issue) => `${issue.path.join(".")}: ${issue.message}`),
|
|
666
|
+
body
|
|
667
|
+
};
|
|
668
|
+
}
|
|
669
|
+
return { frontmatter: checked.data, problems: [], body };
|
|
670
|
+
}
|
|
690
671
|
|
|
691
|
-
// src/
|
|
692
|
-
var RepoIdSchema = z5.string().regex(/^[a-z0-9-]+$/);
|
|
693
|
-
var FeatureNameSchema2 = z5.string().regex(/^[a-z0-9-]+$/);
|
|
694
|
-
var SpecIdSchema = FeatureNameSchema2;
|
|
695
|
-
var DecisionIdSchema = z5.string().regex(/^D\d+$/);
|
|
696
|
-
var SpecStatusSchema = z5.enum(["draft", "validated", "implemented"]);
|
|
697
|
-
var GovernanceFileSchema = z5.enum([
|
|
698
|
-
"AGENTS.md",
|
|
699
|
-
"AGENTS.product.md",
|
|
700
|
-
"FAILURES.md"
|
|
701
|
-
]);
|
|
702
|
-
var MAX_FILE_BYTES = 2e5;
|
|
672
|
+
// ../ogygie-core/src/proposal.ts
|
|
703
673
|
var DECISION_FILES = ["DECISIONS.md", "DECISIONS.product.md"];
|
|
704
674
|
var GithubCoordinatesSchema = z5.object({
|
|
705
675
|
owner: z5.string().min(1),
|
|
706
676
|
repo: z5.string().min(1),
|
|
707
677
|
defaultBranch: z5.string().min(1).default("main")
|
|
708
678
|
});
|
|
709
|
-
var RepoRegistrySchema = z5.object({
|
|
710
|
-
repos: z5.array(
|
|
711
|
-
z5.object({
|
|
712
|
-
id: RepoIdSchema,
|
|
713
|
-
rootPath: z5.string().min(1),
|
|
714
|
-
github: GithubCoordinatesSchema.optional()
|
|
715
|
-
})
|
|
716
|
-
).min(1)
|
|
717
|
-
});
|
|
718
|
-
var ProvenanceSchema = z5.object({
|
|
719
|
-
repo: RepoIdSchema,
|
|
720
|
-
path: z5.string(),
|
|
721
|
-
/** HEAD SHA — null when the served root is not a git clone. */
|
|
722
|
-
ref: z5.string().nullable()
|
|
723
|
-
});
|
|
724
|
-
var ReadManifestInput = { repo: RepoIdSchema };
|
|
725
|
-
var ListSpecsInput = { repo: RepoIdSchema, status: SpecStatusSchema.optional() };
|
|
726
|
-
var ReadSpecInput = { repo: RepoIdSchema, id: SpecIdSchema };
|
|
727
|
-
var ReadDecisionsInput = { repo: RepoIdSchema, decision: DecisionIdSchema.optional() };
|
|
728
|
-
var ReadGovernanceInput = { repo: RepoIdSchema, file: GovernanceFileSchema };
|
|
729
|
-
var ImpactInput = { repo: RepoIdSchema, feature: FeatureNameSchema2 };
|
|
730
|
-
var ReadFeatureInput = {
|
|
731
|
-
repo: RepoIdSchema,
|
|
732
|
-
feature: FeatureNameSchema2,
|
|
733
|
-
file: z5.string().optional()
|
|
734
|
-
};
|
|
735
679
|
var ProposablePathSchema = z5.union([
|
|
736
680
|
z5.string().regex(/^specs\/(?!_)[a-z][a-z0-9]*(-[a-z0-9]+)*\.spec\.md$/),
|
|
737
681
|
z5.literal("DECISIONS.md"),
|
|
738
682
|
z5.literal("DECISIONS.product.md")
|
|
739
683
|
]);
|
|
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
684
|
var ProposalSchema = z5.object({
|
|
747
|
-
repo:
|
|
685
|
+
repo: z5.string(),
|
|
748
686
|
path: ProposablePathSchema,
|
|
749
687
|
branch: z5.string(),
|
|
750
688
|
baseSha: z5.string(),
|
|
751
689
|
commitSha: z5.string(),
|
|
752
690
|
pullRequestNumber: z5.number().int().positive(),
|
|
753
|
-
pullRequestUrl: z5.
|
|
691
|
+
pullRequestUrl: z5.url(),
|
|
754
692
|
/** false when an open pull request was updated instead of created. */
|
|
755
693
|
created: z5.boolean()
|
|
756
694
|
});
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
id: RepoIdSchema,
|
|
760
|
-
/** null when the manifest declares none — the framework's own repo does not. */
|
|
761
|
-
ogygieVersion: z5.string().nullable(),
|
|
762
|
-
ref: z5.string().nullable()
|
|
763
|
-
});
|
|
764
|
-
var SpecSummarySchema = z5.object({
|
|
765
|
-
id: SpecIdSchema,
|
|
766
|
-
status: SpecStatusSchema,
|
|
767
|
-
priority: z5.enum(["P0", "P1", "P2"]),
|
|
768
|
-
dependsOn: z5.array(SpecIdSchema),
|
|
769
|
-
deferred: z5.array(z5.string())
|
|
770
|
-
});
|
|
771
|
-
var SpecListingSchema = z5.object({
|
|
772
|
-
specs: z5.array(SpecSummarySchema),
|
|
773
|
-
invalid: z5.array(z5.object({ id: SpecIdSchema, problems: z5.array(z5.string()) }))
|
|
774
|
-
});
|
|
775
|
-
var SpecDocumentSchema = z5.object({
|
|
776
|
-
frontmatter: SpecFrontmatterSchema.nullable(),
|
|
777
|
-
frontmatterProblems: z5.array(z5.string()),
|
|
778
|
-
body: z5.string(),
|
|
779
|
-
provenance: ProvenanceSchema
|
|
780
|
-
});
|
|
781
|
-
var TextDocumentSchema = z5.object({
|
|
782
|
-
content: z5.string(),
|
|
783
|
-
provenance: ProvenanceSchema
|
|
784
|
-
});
|
|
785
|
-
var FeatureListingSchema = z5.object({
|
|
786
|
-
feature: FeatureNameSchema2,
|
|
787
|
-
files: z5.array(z5.object({ path: z5.string(), sizeBytes: z5.number().int().nonnegative() })),
|
|
788
|
-
provenance: ProvenanceSchema
|
|
789
|
-
});
|
|
790
|
-
var McpErrorCodeSchema = z5.enum([
|
|
791
|
-
"REGISTRY_INVALID",
|
|
792
|
-
"REPO_NOT_OGYGIE",
|
|
793
|
-
"REPO_VERSION_UNSUPPORTED",
|
|
794
|
-
"REPO_UNKNOWN",
|
|
795
|
-
"MANIFEST_INVALID",
|
|
796
|
-
"SPEC_NOT_FOUND",
|
|
797
|
-
"DECISION_NOT_FOUND",
|
|
798
|
-
"FEATURE_NOT_FOUND",
|
|
799
|
-
"FILE_TOO_LARGE",
|
|
800
|
-
"FILE_NOT_TEXT",
|
|
801
|
-
"PATH_OUTSIDE_REPO",
|
|
802
|
-
"UNAUTHORIZED",
|
|
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"
|
|
815
|
-
]);
|
|
816
|
-
var MIN_TOKEN_LENGTH = 32;
|
|
817
|
-
|
|
818
|
-
// src/registry.ts
|
|
819
|
-
var MANIFEST_PATH = "ogygie.manifest.json";
|
|
820
|
-
function parseRegistry(raw) {
|
|
821
|
-
const parsed = RepoRegistrySchema.safeParse(raw);
|
|
695
|
+
function assertProposablePath(path) {
|
|
696
|
+
const parsed = ProposablePathSchema.safeParse(path);
|
|
822
697
|
if (!parsed.success) {
|
|
823
698
|
return throwOgygieError({
|
|
824
|
-
code: "
|
|
825
|
-
message: "
|
|
826
|
-
file:
|
|
827
|
-
cause:
|
|
828
|
-
suggestedFix:
|
|
699
|
+
code: "WRITE_PATH_FORBIDDEN",
|
|
700
|
+
message: `"${path}" is outside what a proposal may touch.`,
|
|
701
|
+
file: path,
|
|
702
|
+
cause: "the perimeter is specs/<id>.spec.md (not the template), DECISIONS.md, DECISIONS.product.md",
|
|
703
|
+
suggestedFix: "Propose a spec or a decisions file; code, manifest, governed files and FAILURES.md are out of reach (D17)."
|
|
829
704
|
});
|
|
830
705
|
}
|
|
831
706
|
return parsed.data;
|
|
832
707
|
}
|
|
833
|
-
function
|
|
834
|
-
const
|
|
835
|
-
const
|
|
708
|
+
function createGithubWriteClient(deps, github) {
|
|
709
|
+
const apiBase = deps.apiBase ?? "https://api.github.com";
|
|
710
|
+
const repoUrl = `${apiBase}/repos/${github.owner}/${github.repo}`;
|
|
711
|
+
const credential = deps.credentialHint ?? "the GitHub token";
|
|
712
|
+
async function request(method, url, body, allowNotFound) {
|
|
713
|
+
let response;
|
|
714
|
+
try {
|
|
715
|
+
response = await deps.fetchImpl(url, {
|
|
716
|
+
method,
|
|
717
|
+
headers: {
|
|
718
|
+
authorization: `Bearer ${deps.token}`,
|
|
719
|
+
accept: "application/vnd.github+json",
|
|
720
|
+
"x-github-api-version": "2022-11-28",
|
|
721
|
+
"user-agent": deps.userAgent ?? "ogygie-core",
|
|
722
|
+
...body === void 0 ? {} : { "content-type": "application/json" }
|
|
723
|
+
},
|
|
724
|
+
...body === void 0 ? {} : { body: JSON.stringify(body) }
|
|
725
|
+
});
|
|
726
|
+
} catch (thrown) {
|
|
727
|
+
return throwOgygieError({
|
|
728
|
+
code: "GITHUB_UNAVAILABLE",
|
|
729
|
+
message: `GitHub could not be reached for ${github.owner}/${github.repo}.`,
|
|
730
|
+
file: null,
|
|
731
|
+
cause: thrown instanceof Error ? thrown.message : String(thrown),
|
|
732
|
+
suggestedFix: "Retry once the network is back \u2014 nothing was written, nothing is queued."
|
|
733
|
+
});
|
|
734
|
+
}
|
|
735
|
+
if (response.status === 404 && allowNotFound) return null;
|
|
736
|
+
if (!response.ok) {
|
|
737
|
+
return throwOgygieError({
|
|
738
|
+
code: "GITHUB_UNAVAILABLE",
|
|
739
|
+
message: `GitHub answered ${response.status} for ${method} ${url.replace(apiBase, "")}.`,
|
|
740
|
+
file: null,
|
|
741
|
+
cause: `status=${response.status}`,
|
|
742
|
+
suggestedFix: response.status === 401 || response.status === 403 ? `Check ${credential}: it must grant contents and pull requests on this repo.` : "Retry later \u2014 nothing was written, nothing is queued."
|
|
743
|
+
});
|
|
744
|
+
}
|
|
745
|
+
return await response.json();
|
|
746
|
+
}
|
|
747
|
+
return {
|
|
748
|
+
async getBranchHead(branch) {
|
|
749
|
+
const ref = await request(
|
|
750
|
+
"GET",
|
|
751
|
+
`${repoUrl}/git/ref/heads/${encodeURIComponent(branch)}`,
|
|
752
|
+
void 0,
|
|
753
|
+
true
|
|
754
|
+
);
|
|
755
|
+
return ref?.object.sha ?? null;
|
|
756
|
+
},
|
|
757
|
+
async readFileAtRef(path, ref) {
|
|
758
|
+
const file = await request(
|
|
759
|
+
"GET",
|
|
760
|
+
`${repoUrl}/contents/${path}?ref=${encodeURIComponent(ref)}`,
|
|
761
|
+
void 0,
|
|
762
|
+
true
|
|
763
|
+
);
|
|
764
|
+
if (file === null) return null;
|
|
765
|
+
return { content: Buffer.from(file.content, "base64").toString("utf8"), sha: file.sha };
|
|
766
|
+
},
|
|
767
|
+
async createBranch(branch, fromSha) {
|
|
768
|
+
await request("POST", `${repoUrl}/git/refs`, { ref: `refs/heads/${branch}`, sha: fromSha }, false);
|
|
769
|
+
},
|
|
770
|
+
async commitFile(input) {
|
|
771
|
+
const result = await request(
|
|
772
|
+
"PUT",
|
|
773
|
+
`${repoUrl}/contents/${input.path}`,
|
|
774
|
+
{
|
|
775
|
+
message: input.message,
|
|
776
|
+
content: Buffer.from(input.content, "utf8").toString("base64"),
|
|
777
|
+
branch: input.branch,
|
|
778
|
+
...input.expectedSha === null ? {} : { sha: input.expectedSha },
|
|
779
|
+
...input.author === void 0 ? {} : { author: input.author }
|
|
780
|
+
},
|
|
781
|
+
false
|
|
782
|
+
);
|
|
783
|
+
return { commitSha: result?.commit.sha ?? "" };
|
|
784
|
+
},
|
|
785
|
+
async findOpenPullRequest(branch) {
|
|
786
|
+
const pulls = await request(
|
|
787
|
+
"GET",
|
|
788
|
+
`${repoUrl}/pulls?state=open&head=${encodeURIComponent(`${github.owner}:${branch}`)}`,
|
|
789
|
+
void 0,
|
|
790
|
+
false
|
|
791
|
+
);
|
|
792
|
+
const first = pulls?.[0];
|
|
793
|
+
return first === void 0 ? null : { number: first.number, url: first.html_url };
|
|
794
|
+
},
|
|
795
|
+
async openPullRequest(input) {
|
|
796
|
+
const pull = await request(
|
|
797
|
+
"POST",
|
|
798
|
+
`${repoUrl}/pulls`,
|
|
799
|
+
{ title: input.title, body: input.body, head: input.branch, base: input.base },
|
|
800
|
+
false
|
|
801
|
+
);
|
|
802
|
+
return { number: pull?.number ?? 0, url: pull?.html_url ?? "" };
|
|
803
|
+
}
|
|
804
|
+
};
|
|
805
|
+
}
|
|
806
|
+
var HOUR_MS = 36e5;
|
|
807
|
+
var DECISION_HEADING2 = /^## D(\d+) — \d{4}-\d{2}-\d{2} — \S/;
|
|
808
|
+
var DECISION_BULLETS = ["**D\xE9cision**", "**Motivation**", "**Exclut**"];
|
|
809
|
+
function specIdOfPath(path) {
|
|
810
|
+
return path.replace(/^specs\//, "").replace(/\.spec\.md$/, "");
|
|
811
|
+
}
|
|
812
|
+
function assertSpecProposable(path, content) {
|
|
813
|
+
const parsed = parseSpecDocument(content);
|
|
814
|
+
const problems = [...parsed.problems];
|
|
815
|
+
if (parsed.frontmatter !== null && parsed.frontmatter.id !== specIdOfPath(path)) {
|
|
816
|
+
problems.push(`id "${parsed.frontmatter.id}" differs from the file name "${specIdOfPath(path)}"`);
|
|
817
|
+
}
|
|
818
|
+
let cursor = 0;
|
|
819
|
+
for (const section of SECTIONS_SPEC_OBLIGATOIRES) {
|
|
820
|
+
const position = parsed.body.indexOf(`
|
|
821
|
+
${section}
|
|
822
|
+
`, cursor);
|
|
823
|
+
if (position === -1) problems.push(`section "${section}" missing or out of order`);
|
|
824
|
+
else cursor = position + section.length;
|
|
825
|
+
}
|
|
826
|
+
if (problems.length > 0) {
|
|
827
|
+
throwOgygieError({
|
|
828
|
+
code: "SPEC_INVALID",
|
|
829
|
+
message: `The proposed spec would fail verify-specs (${problems.length} problem(s)).`,
|
|
830
|
+
file: path,
|
|
831
|
+
cause: problems.join(" ; "),
|
|
832
|
+
suggestedFix: "Fix the frontmatter (id, status, depends_on, priority, refs) and keep the six canonical sections in order."
|
|
833
|
+
});
|
|
834
|
+
}
|
|
835
|
+
if (parsed.frontmatter?.status === "implemented") {
|
|
836
|
+
throwOgygieError({
|
|
837
|
+
code: "SPEC_STATUS_FORBIDDEN",
|
|
838
|
+
message: "A proposal may not declare a spec implemented.",
|
|
839
|
+
file: path,
|
|
840
|
+
cause: "status=implemented",
|
|
841
|
+
suggestedFix: "Propose the spec as draft or validated \u2014 the repo's manifest and CI say when it is implemented."
|
|
842
|
+
});
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
function assertDecisionsProposable(path, content, baseSame, baseOther) {
|
|
846
|
+
const problems = [];
|
|
847
|
+
for (const line of content.split("\n")) {
|
|
848
|
+
if (line.startsWith("## ") && !DECISION_HEADING2.test(line)) {
|
|
849
|
+
problems.push(`heading "${line}" is not "## D<n> \u2014 AAAA-MM-JJ \u2014 <titre>"`);
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
const proposedIds = parseKnownDecisionIds(content);
|
|
853
|
+
const otherIds = parseKnownDecisionIds(baseOther ?? "");
|
|
854
|
+
const previousIds = parseKnownDecisionIds(baseSame ?? "");
|
|
855
|
+
for (const id of previousIds) {
|
|
856
|
+
if (!proposedIds.includes(id)) problems.push(`${id} was declared and is removed or renumbered`);
|
|
857
|
+
}
|
|
858
|
+
const all = [...proposedIds, ...otherIds];
|
|
859
|
+
const seen = /* @__PURE__ */ new Set();
|
|
860
|
+
for (const id of all) {
|
|
861
|
+
if (seen.has(id)) problems.push(`${id} is declared twice across the decision files`);
|
|
862
|
+
seen.add(id);
|
|
863
|
+
}
|
|
864
|
+
const numbers = [...seen].map((id) => Number.parseInt(id.slice(1), 10)).sort((a, b) => a - b);
|
|
865
|
+
numbers.forEach((value, index) => {
|
|
866
|
+
if (value !== index + 1) problems.push(`numbering has a gap: expected D${index + 1}, found D${value}`);
|
|
867
|
+
});
|
|
868
|
+
for (const id of proposedIds.filter((candidate) => !previousIds.includes(candidate))) {
|
|
869
|
+
const section = extractDecisionSection(content, id) ?? "";
|
|
870
|
+
for (const bullet of DECISION_BULLETS) {
|
|
871
|
+
if (!section.includes(bullet)) problems.push(`${id} lacks its ${bullet} bullet`);
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
if (problems.length > 0) {
|
|
875
|
+
throwOgygieError({
|
|
876
|
+
code: "DECISION_INVALID",
|
|
877
|
+
message: `The proposed decisions file breaks the D<n> contract (${problems.length} problem(s)).`,
|
|
878
|
+
file: path,
|
|
879
|
+
cause: problems.join(" ; "),
|
|
880
|
+
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."
|
|
881
|
+
});
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
function assertDecisionReferencesKnown(path, content, known) {
|
|
885
|
+
const unknown = findUnknownDecisionReferences(content, known);
|
|
886
|
+
if (unknown.length > 0) {
|
|
887
|
+
throwOgygieError({
|
|
888
|
+
code: "UNKNOWN_DECISION_REFERENCE",
|
|
889
|
+
message: `The proposal cites ${unknown.length} decision(s) the repo does not declare.`,
|
|
890
|
+
file: path,
|
|
891
|
+
cause: unknown.map((reference) => `${reference.decision} (line ${reference.line})`).join(", "),
|
|
892
|
+
suggestedFix: `Cite only declared decisions (${known.join(", ") || "<none>"}), or propose the decision first.`
|
|
893
|
+
});
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
function branchSlug(path) {
|
|
897
|
+
return path.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/(^-|-$)/g, "");
|
|
898
|
+
}
|
|
899
|
+
function defaultBranchFor(target, path) {
|
|
900
|
+
return `mcp/${target.id}/${branchSlug(path)}`;
|
|
901
|
+
}
|
|
902
|
+
async function withGithub(action) {
|
|
903
|
+
try {
|
|
904
|
+
return await action();
|
|
905
|
+
} catch (thrown) {
|
|
906
|
+
if (thrown instanceof OgygieFailure) throw thrown;
|
|
907
|
+
return throwOgygieError({
|
|
908
|
+
code: "GITHUB_UNAVAILABLE",
|
|
909
|
+
message: "The GitHub client failed outside its contract.",
|
|
910
|
+
file: null,
|
|
911
|
+
cause: thrown instanceof Error ? thrown.message : String(thrown),
|
|
912
|
+
suggestedFix: "Retry \u2014 nothing was written, nothing is queued."
|
|
913
|
+
});
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
async function proposeChange(target, deps, input) {
|
|
917
|
+
const path = assertProposablePath(input.path);
|
|
918
|
+
const github = target.github;
|
|
919
|
+
const now = deps.now();
|
|
920
|
+
const recent = (deps.recentProposals.get(target.id) ?? []).filter((at) => now - at < HOUR_MS);
|
|
921
|
+
if (recent.length >= deps.rateLimitPerHour) {
|
|
922
|
+
const retryAfterSeconds = Math.ceil(((recent[0] ?? now) + HOUR_MS - now) / 1e3);
|
|
923
|
+
throwOgygieError({
|
|
924
|
+
code: "WRITE_RATE_LIMITED",
|
|
925
|
+
message: `Repo "${target.id}" already received ${recent.length} proposals this hour.`,
|
|
926
|
+
file: null,
|
|
927
|
+
cause: `limit=${deps.rateLimitPerHour}/hour`,
|
|
928
|
+
suggestedFix: `Retry in ${retryAfterSeconds} s, or merge or close the open proposals first.`
|
|
929
|
+
});
|
|
930
|
+
}
|
|
931
|
+
const client = deps.clientFor(github);
|
|
932
|
+
const baseSha = await withGithub(() => client.getBranchHead(github.defaultBranch));
|
|
933
|
+
if (baseSha === null) {
|
|
934
|
+
return throwOgygieError({
|
|
935
|
+
code: "GITHUB_UNAVAILABLE",
|
|
936
|
+
message: `Branch "${github.defaultBranch}" does not exist on ${github.owner}/${github.repo}.`,
|
|
937
|
+
file: null,
|
|
938
|
+
cause: "default branch head not found",
|
|
939
|
+
suggestedFix: "Fix `defaultBranch` in the GitHub coordinates of that repo."
|
|
940
|
+
});
|
|
941
|
+
}
|
|
942
|
+
const baseFiles = /* @__PURE__ */ new Map();
|
|
943
|
+
for (const decisionsPath of DECISION_FILES) {
|
|
944
|
+
const file = await withGithub(() => client.readFileAtRef(decisionsPath, baseSha));
|
|
945
|
+
baseFiles.set(decisionsPath, file?.content ?? null);
|
|
946
|
+
}
|
|
947
|
+
if (path.startsWith("specs/")) {
|
|
948
|
+
assertSpecProposable(path, input.content);
|
|
949
|
+
const known = DECISION_FILES.flatMap((file) => parseKnownDecisionIds(baseFiles.get(file) ?? ""));
|
|
950
|
+
assertDecisionReferencesKnown(path, input.content, known);
|
|
951
|
+
} else {
|
|
952
|
+
const other = DECISION_FILES.find((file) => file !== path) ?? DECISION_FILES[1];
|
|
953
|
+
assertDecisionsProposable(path, input.content, baseFiles.get(path) ?? null, baseFiles.get(other) ?? null);
|
|
954
|
+
const known = [...parseKnownDecisionIds(input.content), ...parseKnownDecisionIds(baseFiles.get(other) ?? "")];
|
|
955
|
+
assertDecisionReferencesKnown(path, input.content, known);
|
|
956
|
+
}
|
|
957
|
+
let branch = (deps.branchFor ?? defaultBranchFor)(target, path);
|
|
958
|
+
let branchHead = await withGithub(() => client.getBranchHead(branch));
|
|
959
|
+
let openPull = branchHead === null ? null : await withGithub(() => client.findOpenPullRequest(branch));
|
|
960
|
+
if (branchHead !== null && openPull === null) {
|
|
961
|
+
branch = `${branch}-${baseSha.slice(0, 7)}`;
|
|
962
|
+
branchHead = await withGithub(() => client.getBranchHead(branch));
|
|
963
|
+
openPull = branchHead === null ? null : await withGithub(() => client.findOpenPullRequest(branch));
|
|
964
|
+
}
|
|
965
|
+
const headOfBranch = branchHead;
|
|
966
|
+
const baseFile = await withGithub(() => client.readFileAtRef(path, baseSha));
|
|
967
|
+
const branchFile = headOfBranch === null ? null : await withGithub(() => client.readFileAtRef(path, headOfBranch));
|
|
968
|
+
const readsBranch = deps.readFrom === "branch" && headOfBranch !== null;
|
|
969
|
+
const compared = readsBranch ? branchFile : baseFile;
|
|
970
|
+
const comparedName = readsBranch ? branch : github.defaultBranch;
|
|
971
|
+
const local = await target.adapter.exists(path) ? await target.adapter.readFile(path) : null;
|
|
972
|
+
if ((compared?.content ?? null) !== local) {
|
|
973
|
+
throwOgygieError({
|
|
974
|
+
code: "REMOTE_CONFLICT",
|
|
975
|
+
message: `"${path}" changed on ${comparedName} since it was read.`,
|
|
976
|
+
file: path,
|
|
977
|
+
cause: `head sha=${compared?.sha ?? "<absent>"}, the read copy ${local === null ? "has no such file" : "differs"}`,
|
|
978
|
+
suggestedFix: "Re-read the file (details.headContent is the current head), reapply your change and propose again.",
|
|
979
|
+
details: { headSha: compared?.sha ?? null, headContent: compared?.content ?? null }
|
|
980
|
+
});
|
|
981
|
+
}
|
|
982
|
+
recent.push(now);
|
|
983
|
+
deps.recentProposals.set(target.id, recent);
|
|
984
|
+
const expectedSha = headOfBranch === null ? baseFile?.sha ?? null : branchFile?.sha ?? null;
|
|
985
|
+
if (headOfBranch === null) {
|
|
986
|
+
await withGithub(() => client.createBranch(branch, baseSha));
|
|
987
|
+
}
|
|
988
|
+
const author = deps.authorOf?.();
|
|
989
|
+
const commit = await withGithub(
|
|
990
|
+
() => client.commitFile({
|
|
991
|
+
branch,
|
|
992
|
+
path,
|
|
993
|
+
content: input.content,
|
|
994
|
+
message: input.message,
|
|
995
|
+
expectedSha,
|
|
996
|
+
...author === void 0 ? {} : { author }
|
|
997
|
+
})
|
|
998
|
+
);
|
|
999
|
+
const pull = openPull ?? await withGithub(
|
|
1000
|
+
() => client.openPullRequest({
|
|
1001
|
+
branch,
|
|
1002
|
+
base: github.defaultBranch,
|
|
1003
|
+
title: (input.message.split("\n")[0] ?? input.message).slice(0, 72),
|
|
1004
|
+
body: `${input.message}
|
|
1005
|
+
|
|
1006
|
+
---
|
|
1007
|
+
${deps.originOf(path)}`
|
|
1008
|
+
})
|
|
1009
|
+
);
|
|
1010
|
+
return {
|
|
1011
|
+
repo: target.id,
|
|
1012
|
+
path,
|
|
1013
|
+
branch,
|
|
1014
|
+
baseSha,
|
|
1015
|
+
commitSha: commit.commitSha,
|
|
1016
|
+
pullRequestNumber: pull.number,
|
|
1017
|
+
pullRequestUrl: pull.url,
|
|
1018
|
+
created: openPull === null
|
|
1019
|
+
};
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
// src/schemas.ts
|
|
1023
|
+
import { z as z6 } from "zod";
|
|
1024
|
+
var RepoIdSchema = z6.string().regex(/^[a-z0-9-]+$/);
|
|
1025
|
+
var FeatureNameSchema2 = z6.string().regex(/^[a-z0-9-]+$/);
|
|
1026
|
+
var SpecIdSchema = FeatureNameSchema2;
|
|
1027
|
+
var DecisionIdSchema = z6.string().regex(/^D\d+$/);
|
|
1028
|
+
var SpecStatusSchema = z6.enum(["draft", "validated", "implemented"]);
|
|
1029
|
+
var GovernanceFileSchema = z6.enum([
|
|
1030
|
+
"AGENTS.md",
|
|
1031
|
+
"AGENTS.product.md",
|
|
1032
|
+
"FAILURES.md"
|
|
1033
|
+
]);
|
|
1034
|
+
var MAX_FILE_BYTES = 2e5;
|
|
1035
|
+
var RepoRegistrySchema = z6.object({
|
|
1036
|
+
repos: z6.array(
|
|
1037
|
+
z6.object({
|
|
1038
|
+
id: RepoIdSchema,
|
|
1039
|
+
rootPath: z6.string().min(1),
|
|
1040
|
+
github: GithubCoordinatesSchema.optional()
|
|
1041
|
+
})
|
|
1042
|
+
).min(1)
|
|
1043
|
+
});
|
|
1044
|
+
var ProvenanceSchema = z6.object({
|
|
1045
|
+
repo: RepoIdSchema,
|
|
1046
|
+
path: z6.string(),
|
|
1047
|
+
/** HEAD SHA — null when the served root is not a git clone. */
|
|
1048
|
+
ref: z6.string().nullable()
|
|
1049
|
+
});
|
|
1050
|
+
var ReadManifestInput = { repo: RepoIdSchema };
|
|
1051
|
+
var ListSpecsInput = { repo: RepoIdSchema, status: SpecStatusSchema.optional() };
|
|
1052
|
+
var ReadSpecInput = { repo: RepoIdSchema, id: SpecIdSchema };
|
|
1053
|
+
var ReadDecisionsInput = { repo: RepoIdSchema, decision: DecisionIdSchema.optional() };
|
|
1054
|
+
var ReadGovernanceInput = { repo: RepoIdSchema, file: GovernanceFileSchema };
|
|
1055
|
+
var ImpactInput = { repo: RepoIdSchema, feature: FeatureNameSchema2 };
|
|
1056
|
+
var ReadFeatureInput = {
|
|
1057
|
+
repo: RepoIdSchema,
|
|
1058
|
+
feature: FeatureNameSchema2,
|
|
1059
|
+
file: z6.string().optional()
|
|
1060
|
+
};
|
|
1061
|
+
var ProposalSchema2 = ProposalSchema.extend({ repo: RepoIdSchema });
|
|
1062
|
+
var ProposeChangeInput = {
|
|
1063
|
+
repo: RepoIdSchema,
|
|
1064
|
+
path: z6.string(),
|
|
1065
|
+
content: z6.string().min(1).max(MAX_FILE_BYTES),
|
|
1066
|
+
message: z6.string().min(10).max(2e3)
|
|
1067
|
+
};
|
|
1068
|
+
var DEFAULT_WRITE_RATE_LIMIT = 20;
|
|
1069
|
+
var RepoInfoSchema = z6.object({
|
|
1070
|
+
id: RepoIdSchema,
|
|
1071
|
+
/** null when the manifest declares none — the framework's own repo does not. */
|
|
1072
|
+
ogygieVersion: z6.string().nullable(),
|
|
1073
|
+
ref: z6.string().nullable()
|
|
1074
|
+
});
|
|
1075
|
+
var SpecSummarySchema = z6.object({
|
|
1076
|
+
id: SpecIdSchema,
|
|
1077
|
+
status: SpecStatusSchema,
|
|
1078
|
+
priority: z6.enum(["P0", "P1", "P2"]),
|
|
1079
|
+
dependsOn: z6.array(SpecIdSchema),
|
|
1080
|
+
deferred: z6.array(z6.string())
|
|
1081
|
+
});
|
|
1082
|
+
var SpecListingSchema = z6.object({
|
|
1083
|
+
specs: z6.array(SpecSummarySchema),
|
|
1084
|
+
invalid: z6.array(z6.object({ id: SpecIdSchema, problems: z6.array(z6.string()) }))
|
|
1085
|
+
});
|
|
1086
|
+
var SpecDocumentSchema = z6.object({
|
|
1087
|
+
frontmatter: SpecFrontmatterSchema.nullable(),
|
|
1088
|
+
frontmatterProblems: z6.array(z6.string()),
|
|
1089
|
+
body: z6.string(),
|
|
1090
|
+
provenance: ProvenanceSchema
|
|
1091
|
+
});
|
|
1092
|
+
var TextDocumentSchema = z6.object({
|
|
1093
|
+
content: z6.string(),
|
|
1094
|
+
provenance: ProvenanceSchema
|
|
1095
|
+
});
|
|
1096
|
+
var FeatureListingSchema = z6.object({
|
|
1097
|
+
feature: FeatureNameSchema2,
|
|
1098
|
+
files: z6.array(z6.object({ path: z6.string(), sizeBytes: z6.number().int().nonnegative() })),
|
|
1099
|
+
provenance: ProvenanceSchema
|
|
1100
|
+
});
|
|
1101
|
+
var McpErrorCodeSchema = z6.enum([
|
|
1102
|
+
"REGISTRY_INVALID",
|
|
1103
|
+
"REPO_NOT_OGYGIE",
|
|
1104
|
+
"REPO_VERSION_UNSUPPORTED",
|
|
1105
|
+
"REPO_UNKNOWN",
|
|
1106
|
+
"MANIFEST_INVALID",
|
|
1107
|
+
"SPEC_NOT_FOUND",
|
|
1108
|
+
"DECISION_NOT_FOUND",
|
|
1109
|
+
"FEATURE_NOT_FOUND",
|
|
1110
|
+
"FILE_TOO_LARGE",
|
|
1111
|
+
"FILE_NOT_TEXT",
|
|
1112
|
+
"PATH_OUTSIDE_REPO",
|
|
1113
|
+
"UNAUTHORIZED",
|
|
1114
|
+
"TOKEN_MISSING",
|
|
1115
|
+
// v2 — write by proposal (B10, D17)
|
|
1116
|
+
"GITHUB_TOKEN_MISSING",
|
|
1117
|
+
"WRITE_NOT_CONFIGURED",
|
|
1118
|
+
"WRITE_PATH_FORBIDDEN",
|
|
1119
|
+
"SPEC_INVALID",
|
|
1120
|
+
"SPEC_STATUS_FORBIDDEN",
|
|
1121
|
+
"DECISION_INVALID",
|
|
1122
|
+
"UNKNOWN_DECISION_REFERENCE",
|
|
1123
|
+
"REMOTE_CONFLICT",
|
|
1124
|
+
"WRITE_RATE_LIMITED",
|
|
1125
|
+
"GITHUB_UNAVAILABLE"
|
|
1126
|
+
]);
|
|
1127
|
+
var MIN_TOKEN_LENGTH = 32;
|
|
1128
|
+
|
|
1129
|
+
// src/registry.ts
|
|
1130
|
+
var MANIFEST_PATH = "ogygie.manifest.json";
|
|
1131
|
+
function parseRegistry(raw) {
|
|
1132
|
+
const parsed = RepoRegistrySchema.safeParse(raw);
|
|
1133
|
+
if (!parsed.success) {
|
|
1134
|
+
return throwOgygieError({
|
|
1135
|
+
code: "REGISTRY_INVALID",
|
|
1136
|
+
message: "The MCP repo registry does not match RepoRegistrySchema.",
|
|
1137
|
+
file: null,
|
|
1138
|
+
cause: parsed.error.issues.map((issue) => `${issue.path.join(".")}: ${issue.message}`).join(" ; "),
|
|
1139
|
+
suggestedFix: 'Write { "repos": [{ "id": "<kebab-case>", "rootPath": "<path>" }] } with at least one entry.'
|
|
1140
|
+
});
|
|
1141
|
+
}
|
|
1142
|
+
return parsed.data;
|
|
1143
|
+
}
|
|
1144
|
+
function isNewerThan(candidate, reference) {
|
|
1145
|
+
const toParts = (version) => version.split(".").map((part) => Number.parseInt(part, 10) || 0);
|
|
1146
|
+
const left = toParts(candidate);
|
|
836
1147
|
const right = toParts(reference);
|
|
837
1148
|
for (let index = 0; index < Math.max(left.length, right.length); index += 1) {
|
|
838
1149
|
const a = left[index] ?? 0;
|
|
@@ -895,84 +1206,58 @@ async function openRepos(registry, deps) {
|
|
|
895
1206
|
message: `Repo "${declared.id}" pins Ogygie ${repo.ogygieVersion}, newer than this server (${deps.serverVersion}).`,
|
|
896
1207
|
file: MANIFEST_PATH,
|
|
897
1208
|
cause: `ogygieVersion=${repo.ogygieVersion} > server=${deps.serverVersion}`,
|
|
898
|
-
suggestedFix: `Update the server to a version \u2265 ${repo.ogygieVersion}: pnpm update @ogygie/mcp@${repo.ogygieVersion}`
|
|
899
|
-
});
|
|
900
|
-
}
|
|
901
|
-
opened.push(repo);
|
|
902
|
-
}
|
|
903
|
-
return opened;
|
|
904
|
-
}
|
|
905
|
-
function repoOf(repos, repoId) {
|
|
906
|
-
const found = repos.find((candidate) => candidate.id === repoId);
|
|
907
|
-
if (found === void 0) {
|
|
908
|
-
return throwOgygieError({
|
|
909
|
-
code: "REPO_UNKNOWN",
|
|
910
|
-
message: `No repo "${repoId}" is registered on this server.`,
|
|
911
|
-
file: null,
|
|
912
|
-
cause: `known repos: ${repos.map((repo) => repo.id).join(", ") || "<none>"}`,
|
|
913
|
-
suggestedFix: "Call ogygie_list_repos to see what this server serves."
|
|
914
|
-
});
|
|
915
|
-
}
|
|
916
|
-
return found;
|
|
917
|
-
}
|
|
918
|
-
|
|
919
|
-
// src/server.ts
|
|
920
|
-
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
921
|
-
|
|
922
|
-
// ../ogygie-core/src/decision-refs.ts
|
|
923
|
-
var DECISION_REFERENCE = /(?<![A-Za-z0-9])D(\d+)(?![A-Za-z0-9])/g;
|
|
924
|
-
var DECISION_HEADING = /^##\s+D(\d+)\b/;
|
|
925
|
-
function parseKnownDecisionIds(decisionsMarkdown) {
|
|
926
|
-
const ids = [];
|
|
927
|
-
for (const line of decisionsMarkdown.split("\n")) {
|
|
928
|
-
const heading = DECISION_HEADING.exec(line);
|
|
929
|
-
if (heading !== null) ids.push(`D${heading[1]}`);
|
|
930
|
-
}
|
|
931
|
-
return ids;
|
|
932
|
-
}
|
|
933
|
-
function extractDecisionSection(decisionsMarkdown, decisionId) {
|
|
934
|
-
const lines = decisionsMarkdown.split("\n");
|
|
935
|
-
const trimTrailingBlanks = (section) => {
|
|
936
|
-
while (section.length > 0 && (section[section.length - 1] ?? "").trim() === "") section.pop();
|
|
937
|
-
return section.join("\n");
|
|
938
|
-
};
|
|
939
|
-
let start = -1;
|
|
940
|
-
for (let index = 0; index < lines.length; index += 1) {
|
|
941
|
-
const heading = DECISION_HEADING.exec(lines[index] ?? "");
|
|
942
|
-
if (heading === null) continue;
|
|
943
|
-
if (start === -1) {
|
|
944
|
-
if (`D${heading[1]}` === decisionId) start = index;
|
|
945
|
-
continue;
|
|
946
|
-
}
|
|
947
|
-
return trimTrailingBlanks(lines.slice(start, index));
|
|
948
|
-
}
|
|
949
|
-
return start === -1 ? null : trimTrailingBlanks(lines.slice(start));
|
|
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 });
|
|
1209
|
+
suggestedFix: `Update the server to a version \u2265 ${repo.ogygieVersion}: pnpm update @ogygie/mcp@${repo.ogygieVersion}`
|
|
1210
|
+
});
|
|
958
1211
|
}
|
|
1212
|
+
opened.push(repo);
|
|
959
1213
|
}
|
|
960
|
-
return
|
|
1214
|
+
return opened;
|
|
961
1215
|
}
|
|
962
|
-
function
|
|
963
|
-
const
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
1216
|
+
function repoOf(repos, repoId) {
|
|
1217
|
+
const found = repos.find((candidate) => candidate.id === repoId);
|
|
1218
|
+
if (found === void 0) {
|
|
1219
|
+
return throwOgygieError({
|
|
1220
|
+
code: "REPO_UNKNOWN",
|
|
1221
|
+
message: `No repo "${repoId}" is registered on this server.`,
|
|
1222
|
+
file: null,
|
|
1223
|
+
cause: `known repos: ${repos.map((repo) => repo.id).join(", ") || "<none>"}`,
|
|
1224
|
+
suggestedFix: "Call ogygie_list_repos to see what this server serves."
|
|
1225
|
+
});
|
|
970
1226
|
}
|
|
971
|
-
return
|
|
1227
|
+
return found;
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
// src/server.ts
|
|
1231
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
1232
|
+
|
|
1233
|
+
// src/proposals.ts
|
|
1234
|
+
async function proposeChange2(repo, deps, input) {
|
|
1235
|
+
assertProposablePath(input.path);
|
|
1236
|
+
const github = repo.github;
|
|
1237
|
+
if (github === void 0) {
|
|
1238
|
+
return throwOgygieError({
|
|
1239
|
+
code: "WRITE_NOT_CONFIGURED",
|
|
1240
|
+
message: `Repo "${repo.id}" declares no GitHub coordinates: it is read-only.`,
|
|
1241
|
+
file: null,
|
|
1242
|
+
cause: "registry entry without a `github` block",
|
|
1243
|
+
suggestedFix: 'Add `"github": { "owner": "\u2026", "repo": "\u2026", "defaultBranch": "main" }` to that repo in the registry.'
|
|
1244
|
+
});
|
|
1245
|
+
}
|
|
1246
|
+
return proposeChange(
|
|
1247
|
+
{ id: repo.id, github, adapter: repo.adapter },
|
|
1248
|
+
{
|
|
1249
|
+
clientFor: deps.clientFor,
|
|
1250
|
+
now: deps.now,
|
|
1251
|
+
rateLimitPerHour: deps.rateLimitPerHour,
|
|
1252
|
+
recentProposals: deps.recentProposals,
|
|
1253
|
+
originOf: (path) => `Propos\xE9 via @ogygie/mcp par ${deps.clientName()} (repo \`${repo.id}\`, chemin \`${path}\`).`
|
|
1254
|
+
},
|
|
1255
|
+
input
|
|
1256
|
+
);
|
|
972
1257
|
}
|
|
973
1258
|
|
|
974
1259
|
// ../ogygie-core/src/impact.ts
|
|
975
|
-
import { z as
|
|
1260
|
+
import { z as z7 } from "zod";
|
|
976
1261
|
|
|
977
1262
|
// ../ogygie-core/src/manifest-owners.ts
|
|
978
1263
|
var ADVANCEMENT = ["skeleton", "in-progress", "delivered"];
|
|
@@ -1091,35 +1376,35 @@ function dbTableNamesOf(feature) {
|
|
|
1091
1376
|
}
|
|
1092
1377
|
|
|
1093
1378
|
// ../ogygie-core/src/impact.ts
|
|
1094
|
-
var MapEntryKindSchema =
|
|
1095
|
-
var ImpactScopeSchema =
|
|
1096
|
-
scannedFolders:
|
|
1097
|
-
coveredKinds:
|
|
1098
|
-
outOfReach:
|
|
1379
|
+
var MapEntryKindSchema = z7.enum(["feature", "package", "framework-package"]);
|
|
1380
|
+
var ImpactScopeSchema = z7.object({
|
|
1381
|
+
scannedFolders: z7.array(z7.string()),
|
|
1382
|
+
coveredKinds: z7.array(MapEntryKindSchema),
|
|
1383
|
+
outOfReach: z7.array(z7.string())
|
|
1099
1384
|
});
|
|
1100
|
-
var ImpactReportSchema =
|
|
1101
|
-
target:
|
|
1102
|
-
name:
|
|
1385
|
+
var ImpactReportSchema = z7.object({
|
|
1386
|
+
target: z7.object({
|
|
1387
|
+
name: z7.string(),
|
|
1103
1388
|
kind: MapEntryKindSchema,
|
|
1104
|
-
folder:
|
|
1389
|
+
folder: z7.string()
|
|
1105
1390
|
}),
|
|
1106
|
-
dependents:
|
|
1107
|
-
|
|
1108
|
-
name:
|
|
1391
|
+
dependents: z7.array(
|
|
1392
|
+
z7.object({
|
|
1393
|
+
name: z7.string(),
|
|
1109
1394
|
kind: MapEntryKindSchema,
|
|
1110
|
-
importedSymbols:
|
|
1111
|
-
viaIndexOnly:
|
|
1395
|
+
importedSymbols: z7.array(z7.string()),
|
|
1396
|
+
viaIndexOnly: z7.boolean()
|
|
1112
1397
|
})
|
|
1113
1398
|
),
|
|
1114
|
-
routes:
|
|
1115
|
-
|
|
1399
|
+
routes: z7.array(
|
|
1400
|
+
z7.object({ method: z7.string(), path: z7.string(), description: z7.string() })
|
|
1116
1401
|
),
|
|
1117
|
-
ownedTables:
|
|
1118
|
-
tableReadersOutsideOwner:
|
|
1119
|
-
|
|
1402
|
+
ownedTables: z7.array(z7.string()),
|
|
1403
|
+
tableReadersOutsideOwner: z7.array(
|
|
1404
|
+
z7.object({ table: z7.string(), feature: z7.string(), file: z7.string() })
|
|
1120
1405
|
),
|
|
1121
|
-
findings:
|
|
1122
|
-
testCommand:
|
|
1406
|
+
findings: z7.array(OgygieErrorSchema),
|
|
1407
|
+
testCommand: z7.string(),
|
|
1123
1408
|
scope: ImpactScopeSchema
|
|
1124
1409
|
});
|
|
1125
1410
|
var SOURCE_EXTENSIONS = [".ts", ".tsx"];
|
|
@@ -1387,38 +1672,11 @@ async function specFileNames(repo) {
|
|
|
1387
1672
|
const entries = await repo.adapter.listDirectory(SPECS_DIR);
|
|
1388
1673
|
return entries.map((entry) => entry.path).filter((path) => !path.includes("/") && path.endsWith(".spec.md") && !path.startsWith("_")).sort();
|
|
1389
1674
|
}
|
|
1390
|
-
var FRONTMATTER_BLOCK = /^---\n([\s\S]*?)\n---/;
|
|
1391
|
-
function parseSpecFile(content) {
|
|
1392
|
-
const match = FRONTMATTER_BLOCK.exec(content);
|
|
1393
|
-
if (match === null || match[1] === void 0) {
|
|
1394
|
-
return {
|
|
1395
|
-
frontmatter: null,
|
|
1396
|
-
problems: ["file does not start with a --- ... --- frontmatter block"],
|
|
1397
|
-
body: content
|
|
1398
|
-
};
|
|
1399
|
-
}
|
|
1400
|
-
const body = content.slice(match[0].length);
|
|
1401
|
-
const parsed = parseSpecFrontmatter(match[1]);
|
|
1402
|
-
if (parsed.problems.length > 0) {
|
|
1403
|
-
return { frontmatter: null, problems: parsed.problems, body };
|
|
1404
|
-
}
|
|
1405
|
-
const checked = SpecFrontmatterSchema.safeParse(parsed.value);
|
|
1406
|
-
if (!checked.success) {
|
|
1407
|
-
return {
|
|
1408
|
-
frontmatter: null,
|
|
1409
|
-
problems: checked.error.issues.map(
|
|
1410
|
-
(issue) => `${issue.path.join(".")}: ${issue.message}`
|
|
1411
|
-
),
|
|
1412
|
-
body
|
|
1413
|
-
};
|
|
1414
|
-
}
|
|
1415
|
-
return { frontmatter: checked.data, problems: [], body };
|
|
1416
|
-
}
|
|
1417
1675
|
async function listSpecs(repo, input) {
|
|
1418
1676
|
const specs = [];
|
|
1419
1677
|
const invalid = [];
|
|
1420
1678
|
for (const fileName of await specFileNames(repo)) {
|
|
1421
|
-
const parsed =
|
|
1679
|
+
const parsed = parseSpecDocument(await readDocument(repo, `${SPECS_DIR}/${fileName}`));
|
|
1422
1680
|
if (parsed.frontmatter === null) {
|
|
1423
1681
|
invalid.push({ id: specIdOf2(fileName), problems: parsed.problems });
|
|
1424
1682
|
continue;
|
|
@@ -1446,7 +1704,7 @@ async function readSpec(repo, input) {
|
|
|
1446
1704
|
suggestedFix: `Existing specs: ${known.join(", ") || "<none>"}.`
|
|
1447
1705
|
});
|
|
1448
1706
|
}
|
|
1449
|
-
const parsed =
|
|
1707
|
+
const parsed = parseSpecDocument(await readDocument(repo, path));
|
|
1450
1708
|
return {
|
|
1451
1709
|
frontmatter: parsed.frontmatter,
|
|
1452
1710
|
frontmatterProblems: parsed.problems,
|
|
@@ -1524,228 +1782,6 @@ async function readFeature(repo, input) {
|
|
|
1524
1782
|
};
|
|
1525
1783
|
}
|
|
1526
1784
|
|
|
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
|
-
|
|
1749
1785
|
// src/server.ts
|
|
1750
1786
|
function asText(payload) {
|
|
1751
1787
|
return { content: [{ type: "text", text: JSON.stringify(payload, null, 2) }] };
|
|
@@ -1851,7 +1887,7 @@ function createOgygieMcpServer(deps) {
|
|
|
1851
1887
|
"ogygie_propose_change",
|
|
1852
1888
|
"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
1889
|
ProposeChangeInput,
|
|
1854
|
-
async (input) =>
|
|
1890
|
+
async (input) => proposeChange2(
|
|
1855
1891
|
repoOf(deps.repos, input.repo),
|
|
1856
1892
|
{
|
|
1857
1893
|
...write,
|
|
@@ -2047,7 +2083,10 @@ async function startOgygieMcp(options) {
|
|
|
2047
2083
|
}
|
|
2048
2084
|
const githubToken = options.githubToken;
|
|
2049
2085
|
const write = githubToken === void 0 ? void 0 : {
|
|
2050
|
-
clientFor: (github) => createGithubWriteClient(
|
|
2086
|
+
clientFor: (github) => createGithubWriteClient(
|
|
2087
|
+
{ token: githubToken, fetchImpl: fetch, userAgent: "ogygie-mcp", credentialHint: "OGYGIE_MCP_GITHUB_TOKEN" },
|
|
2088
|
+
github
|
|
2089
|
+
),
|
|
2051
2090
|
now: () => Date.now(),
|
|
2052
2091
|
rateLimitPerHour: parsed.writeRateLimit,
|
|
2053
2092
|
recentProposals: /* @__PURE__ */ new Map()
|