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