@ogygie/mcp 0.18.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.
Files changed (3) hide show
  1. package/dist/mcp.js +600 -561
  2. package/dist/server.js +600 -561
  3. 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/github-client.ts
208
- function createGithubWriteClient(deps, github) {
209
- const apiBase = deps.apiBase ?? "https://api.github.com";
210
- const repoUrl = `${apiBase}/repos/${github.owner}/${github.repo}`;
211
- async function request(method, url, body, allowNotFound) {
212
- let response;
213
- try {
214
- response = await deps.fetchImpl(url, {
215
- method,
216
- headers: {
217
- authorization: `Bearer ${deps.token}`,
218
- accept: "application/vnd.github+json",
219
- "x-github-api-version": "2022-11-28",
220
- "user-agent": "ogygie-mcp",
221
- ...body === void 0 ? {} : { "content-type": "application/json" }
222
- },
223
- ...body === void 0 ? {} : { body: JSON.stringify(body) }
224
- });
225
- } catch (thrown) {
226
- return throwOgygieError({
227
- code: "GITHUB_UNAVAILABLE",
228
- message: `GitHub could not be reached for ${github.owner}/${github.repo}.`,
229
- file: null,
230
- cause: thrown instanceof Error ? thrown.message : String(thrown),
231
- suggestedFix: "Retry once the network is back \u2014 nothing was written, nothing is queued."
232
- });
233
- }
234
- if (response.status === 404 && allowNotFound) return null;
235
- if (!response.ok) {
236
- return throwOgygieError({
237
- code: "GITHUB_UNAVAILABLE",
238
- message: `GitHub answered ${response.status} for ${method} ${url.replace(apiBase, "")}.`,
239
- file: null,
240
- cause: `status=${response.status}`,
241
- suggestedFix: response.status === 401 || response.status === 403 ? "Check OGYGIE_MCP_GITHUB_TOKEN: it must grant contents and pull requests on this repo." : "Retry later \u2014 nothing was written, nothing is queued."
242
- });
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 await response.json();
235
+ return trimTrailingBlanks(lines.slice(start, index));
245
236
  }
246
- return {
247
- async getBranchHead(branch) {
248
- const ref = await request(
249
- "GET",
250
- `${repoUrl}/git/ref/heads/${encodeURIComponent(branch)}`,
251
- void 0,
252
- true
253
- );
254
- return ref?.object.sha ?? null;
255
- },
256
- async readFileAtRef(path, ref) {
257
- const file = await request(
258
- "GET",
259
- `${repoUrl}/contents/${path}?ref=${encodeURIComponent(ref)}`,
260
- void 0,
261
- true
262
- );
263
- if (file === null) return null;
264
- return { content: Buffer.from(file.content, "base64").toString("utf8"), sha: file.sha };
265
- },
266
- async createBranch(branch, fromSha) {
267
- await request("POST", `${repoUrl}/git/refs`, { ref: `refs/heads/${branch}`, sha: fromSha }, false);
268
- },
269
- async commitFile(input) {
270
- const result = await request(
271
- "PUT",
272
- `${repoUrl}/contents/${input.path}`,
273
- {
274
- message: input.message,
275
- content: Buffer.from(input.content, "utf8").toString("base64"),
276
- branch: input.branch,
277
- ...input.expectedSha === null ? {} : { sha: input.expectedSha }
278
- },
279
- false
280
- );
281
- return { commitSha: result?.commit.sha ?? "" };
282
- },
283
- async findOpenPullRequest(branch) {
284
- const pulls = await request(
285
- "GET",
286
- `${repoUrl}/pulls?state=open&head=${encodeURIComponent(`${github.owner}:${branch}`)}`,
287
- void 0,
288
- false
289
- );
290
- const first = pulls?.[0];
291
- return first === void 0 ? null : { number: first.number, url: first.html_url };
292
- },
293
- async openPullRequest(input) {
294
- const pull = await request(
295
- "POST",
296
- `${repoUrl}/pulls`,
297
- { title: input.title, body: input.body, head: input.branch, base: input.base },
298
- false
299
- );
300
- return { number: pull?.number ?? 0, url: pull?.html_url ?? "" };
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/schemas.ts
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: RepoIdSchema,
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.string().url(),
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
- var DEFAULT_WRITE_RATE_LIMIT = 20;
763
- var RepoInfoSchema = z5.object({
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: "REGISTRY_INVALID",
830
- message: "The MCP repo registry does not match RepoRegistrySchema.",
831
- file: null,
832
- cause: parsed.error.issues.map((issue) => `${issue.path.join(".")}: ${issue.message}`).join(" ; "),
833
- suggestedFix: 'Write { "repos": [{ "id": "<kebab-case>", "rootPath": "<path>" }] } with at least one entry.'
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 isNewerThan(candidate, reference) {
839
- const toParts = (version) => version.split(".").map((part) => Number.parseInt(part, 10) || 0);
840
- const left = toParts(candidate);
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 references;
1219
+ return opened;
966
1220
  }
967
- function findUnknownDecisionReferences(text, knownDecisionIds) {
968
- const known = new Set(knownDecisionIds);
969
- const unknown = [];
970
- const alreadyReported = /* @__PURE__ */ new Set();
971
- for (const reference of findDecisionReferences(text)) {
972
- if (known.has(reference.decision) || alreadyReported.has(reference.decision)) continue;
973
- alreadyReported.add(reference.decision);
974
- unknown.push(reference);
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 unknown;
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 z6 } from "zod";
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 = z6.enum(["feature", "package", "framework-package"]);
1100
- var ImpactScopeSchema = z6.object({
1101
- scannedFolders: z6.array(z6.string()),
1102
- coveredKinds: z6.array(MapEntryKindSchema),
1103
- outOfReach: z6.array(z6.string())
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 = z6.object({
1106
- target: z6.object({
1107
- name: z6.string(),
1390
+ var ImpactReportSchema = z7.object({
1391
+ target: z7.object({
1392
+ name: z7.string(),
1108
1393
  kind: MapEntryKindSchema,
1109
- folder: z6.string()
1394
+ folder: z7.string()
1110
1395
  }),
1111
- dependents: z6.array(
1112
- z6.object({
1113
- name: z6.string(),
1396
+ dependents: z7.array(
1397
+ z7.object({
1398
+ name: z7.string(),
1114
1399
  kind: MapEntryKindSchema,
1115
- importedSymbols: z6.array(z6.string()),
1116
- viaIndexOnly: z6.boolean()
1400
+ importedSymbols: z7.array(z7.string()),
1401
+ viaIndexOnly: z7.boolean()
1117
1402
  })
1118
1403
  ),
1119
- routes: z6.array(
1120
- z6.object({ method: z6.string(), path: z6.string(), description: z6.string() })
1404
+ routes: z7.array(
1405
+ z7.object({ method: z7.string(), path: z7.string(), description: z7.string() })
1121
1406
  ),
1122
- ownedTables: z6.array(z6.string()),
1123
- tableReadersOutsideOwner: z6.array(
1124
- z6.object({ table: z6.string(), feature: z6.string(), file: z6.string() })
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: z6.array(OgygieErrorSchema),
1127
- testCommand: z6.string(),
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 = parseSpecFile(await readDocument(repo, `${SPECS_DIR}/${fileName}`));
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 = parseSpecFile(await readDocument(repo, path));
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) => proposeChange(
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({ token: githubToken, fetchImpl: fetch }, github),
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()