@ogygie/mcp 0.19.0 → 0.21.0

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