@intentius/chant 0.84.0 → 0.86.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 (152) hide show
  1. package/dist/cli/main.d.ts.map +1 -1
  2. package/dist/cli/mcp/resource-handlers.d.ts +2 -1
  3. package/dist/cli/mcp/resource-handlers.d.ts.map +1 -1
  4. package/dist/cli/mcp/server.d.ts +1 -0
  5. package/dist/cli/mcp/server.d.ts.map +1 -1
  6. package/dist/cli/mcp/tools/composites.d.ts +44 -0
  7. package/dist/cli/mcp/tools/composites.d.ts.map +1 -0
  8. package/dist/cli/mcp/tools/search.d.ts.map +1 -1
  9. package/dist/cli/registry.d.ts +13 -1
  10. package/dist/cli/registry.d.ts.map +1 -1
  11. package/dist/components/cli-support.d.ts +4 -0
  12. package/dist/components/cli-support.d.ts.map +1 -1
  13. package/dist/composite.d.ts +6 -0
  14. package/dist/composite.d.ts.map +1 -1
  15. package/dist/lexicon.d.ts +44 -0
  16. package/dist/lexicon.d.ts.map +1 -1
  17. package/dist/lifecycle/gate-ledger.d.ts +13 -0
  18. package/dist/lifecycle/gate-ledger.d.ts.map +1 -1
  19. package/dist/workspace/__fixtures__/contract-repo.d.ts +7 -0
  20. package/dist/workspace/__fixtures__/contract-repo.d.ts.map +1 -1
  21. package/dist/workspace/__fixtures__/sessions.d.ts +23 -0
  22. package/dist/workspace/__fixtures__/sessions.d.ts.map +1 -0
  23. package/dist/workspace/checks/records.d.ts +1 -0
  24. package/dist/workspace/checks/records.d.ts.map +1 -1
  25. package/dist/workspace/checks.d.ts +4 -0
  26. package/dist/workspace/checks.d.ts.map +1 -1
  27. package/dist/workspace/composites.d.ts +152 -0
  28. package/dist/workspace/composites.d.ts.map +1 -0
  29. package/dist/workspace/conformance/index.d.ts +211 -0
  30. package/dist/workspace/conformance/index.d.ts.map +1 -0
  31. package/dist/workspace/conformance/vitest.d.ts +11 -0
  32. package/dist/workspace/conformance/vitest.d.ts.map +1 -0
  33. package/dist/workspace/declaration.d.ts +28 -0
  34. package/dist/workspace/declaration.d.ts.map +1 -1
  35. package/dist/workspace/declaration.schema.json +40 -0
  36. package/dist/workspace/declared-kinds.d.ts +43 -0
  37. package/dist/workspace/declared-kinds.d.ts.map +1 -0
  38. package/dist/workspace/graph-cli.d.ts +24 -2
  39. package/dist/workspace/graph-cli.d.ts.map +1 -1
  40. package/dist/workspace/intent-cli.d.ts +6 -1
  41. package/dist/workspace/intent-cli.d.ts.map +1 -1
  42. package/dist/workspace/intent-joins.d.ts +74 -9
  43. package/dist/workspace/intent-joins.d.ts.map +1 -1
  44. package/dist/workspace/intent.d.ts +90 -6
  45. package/dist/workspace/intent.d.ts.map +1 -1
  46. package/dist/workspace/ls.d.ts +31 -1
  47. package/dist/workspace/ls.d.ts.map +1 -1
  48. package/dist/workspace/member-commands.d.ts +7 -2
  49. package/dist/workspace/member-commands.d.ts.map +1 -1
  50. package/dist/workspace/reason-codes.d.ts +52 -2
  51. package/dist/workspace/reason-codes.d.ts.map +1 -1
  52. package/dist/workspace/record-sessions.d.ts +51 -0
  53. package/dist/workspace/record-sessions.d.ts.map +1 -0
  54. package/dist/workspace/record-source.d.ts +2 -0
  55. package/dist/workspace/record-source.d.ts.map +1 -1
  56. package/dist/workspace/records-cli.d.ts +65 -4
  57. package/dist/workspace/records-cli.d.ts.map +1 -1
  58. package/dist/workspace/records-since.d.ts +90 -0
  59. package/dist/workspace/records-since.d.ts.map +1 -0
  60. package/dist/workspace/records-write.d.ts +164 -0
  61. package/dist/workspace/records-write.d.ts.map +1 -0
  62. package/dist/workspace/records.d.ts +202 -15
  63. package/dist/workspace/records.d.ts.map +1 -1
  64. package/dist/workspace/runtimes.d.ts +60 -0
  65. package/dist/workspace/runtimes.d.ts.map +1 -0
  66. package/dist/workspace/status-gates.d.ts +90 -0
  67. package/dist/workspace/status-gates.d.ts.map +1 -0
  68. package/dist/workspace/status.d.ts +17 -0
  69. package/dist/workspace/status.d.ts.map +1 -1
  70. package/dist/workspace/work.d.ts +56 -0
  71. package/dist/workspace/work.d.ts.map +1 -0
  72. package/package.json +19 -1
  73. package/src/cli/handlers/graph.ts +4 -0
  74. package/src/cli/main.test.ts +9 -0
  75. package/src/cli/main.ts +56 -3
  76. package/src/cli/mcp/resource-handlers.ts +17 -0
  77. package/src/cli/mcp/server.test.ts +140 -4
  78. package/src/cli/mcp/server.ts +5 -1
  79. package/src/cli/mcp/tools/composites.ts +98 -0
  80. package/src/cli/mcp/tools/search.ts +47 -5
  81. package/src/cli/registry.ts +13 -1
  82. package/src/components/cli-support.test.ts +16 -0
  83. package/src/components/cli-support.ts +8 -2
  84. package/src/composite.ts +9 -0
  85. package/src/lexicon.ts +47 -0
  86. package/src/lifecycle/gate-ledger.ts +14 -0
  87. package/src/workspace/__fixtures__/contract-repo.ts +17 -0
  88. package/src/workspace/__fixtures__/sessions.ts +66 -0
  89. package/src/workspace/checks/records.ts +19 -0
  90. package/src/workspace/checks.test.ts +2 -0
  91. package/src/workspace/checks.ts +7 -1
  92. package/src/workspace/composites.schema.json +533 -0
  93. package/src/workspace/composites.test.ts +334 -0
  94. package/src/workspace/composites.ts +316 -0
  95. package/src/workspace/conformance/__fixture__/app/package.json +7 -0
  96. package/src/workspace/conformance/__fixture__/app/src/server.mjs +29 -0
  97. package/src/workspace/conformance/__fixture__/decisions/decision.kind.mjs +32 -0
  98. package/src/workspace/conformance/__fixture__/decisions/decision.schema.json +364 -0
  99. package/src/workspace/conformance/__fixture__/decisions/fix-001-how-the-app-is-deployed.md +40 -0
  100. package/src/workspace/conformance/__fixture__/delivery/chant.config.ts +7 -0
  101. package/src/workspace/conformance/__fixture__/delivery/lexicon/index.ts +26 -0
  102. package/src/workspace/conformance/__fixture__/delivery/package.json +7 -0
  103. package/src/workspace/conformance/__fixture__/delivery/src/app.component.ts +14 -0
  104. package/src/workspace/conformance/__fixture__/delivery/src/app.ts +4 -0
  105. package/src/workspace/conformance/conformance.test.ts +149 -0
  106. package/src/workspace/conformance/index.mjs +31 -0
  107. package/src/workspace/conformance/index.ts +453 -0
  108. package/src/workspace/conformance/vitest.ts +62 -0
  109. package/src/workspace/declaration.schema.json +40 -0
  110. package/src/workspace/declaration.ts +62 -0
  111. package/src/workspace/declared-kinds.test.ts +321 -0
  112. package/src/workspace/declared-kinds.ts +76 -0
  113. package/src/workspace/graph-cli.ts +40 -4
  114. package/src/workspace/intent-cli.ts +54 -7
  115. package/src/workspace/intent-joins.test.ts +60 -0
  116. package/src/workspace/intent-joins.ts +117 -20
  117. package/src/workspace/intent.schema.json +357 -19
  118. package/src/workspace/intent.test.ts +235 -20
  119. package/src/workspace/intent.ts +396 -51
  120. package/src/workspace/ls.schema.json +34 -0
  121. package/src/workspace/ls.ts +69 -4
  122. package/src/workspace/member-commands.ts +11 -5
  123. package/src/workspace/read-contract.test.ts +52 -3
  124. package/src/workspace/reason-codes.test.ts +48 -4
  125. package/src/workspace/reason-codes.ts +67 -2
  126. package/src/workspace/record-assets.test.ts +3 -1
  127. package/src/workspace/record-sessions.ts +105 -0
  128. package/src/workspace/record-source.ts +14 -5
  129. package/src/workspace/records-amend.schema.json +167 -0
  130. package/src/workspace/records-cli.ts +246 -19
  131. package/src/workspace/records-contract.test.ts +77 -2
  132. package/src/workspace/records-formats.test.ts +640 -0
  133. package/src/workspace/records-new.schema.json +158 -0
  134. package/src/workspace/records-quorum.test.ts +196 -0
  135. package/src/workspace/records-review.schema.json +202 -0
  136. package/src/workspace/records-sessions.test.ts +108 -0
  137. package/src/workspace/records-since.schema.json +193 -0
  138. package/src/workspace/records-since.test.ts +174 -0
  139. package/src/workspace/records-since.ts +259 -0
  140. package/src/workspace/records-write-contract.test.ts +125 -0
  141. package/src/workspace/records-write.test.ts +373 -0
  142. package/src/workspace/records-write.ts +736 -0
  143. package/src/workspace/records.schema.json +187 -9
  144. package/src/workspace/records.test.ts +93 -0
  145. package/src/workspace/records.ts +683 -49
  146. package/src/workspace/runtimes.ts +107 -0
  147. package/src/workspace/status-contract.test.ts +163 -0
  148. package/src/workspace/status-gates.ts +215 -0
  149. package/src/workspace/status.schema.json +69 -3
  150. package/src/workspace/status.ts +35 -2
  151. package/src/workspace/work.test.ts +388 -0
  152. package/src/workspace/work.ts +163 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@intentius/chant",
3
- "version": "0.84.0",
3
+ "version": "0.86.0",
4
4
  "description": "Declarative infrastructure-as-code toolkit — TypeScript on Node.js",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://intentius.io/chant",
@@ -50,6 +50,16 @@
50
50
  "default": "./src/cli/index.ts"
51
51
  },
52
52
  "./workspace/*.schema.json": "./src/workspace/*.schema.json",
53
+ "./workspace/conformance": {
54
+ "development": "./src/workspace/conformance/index.ts",
55
+ "types": "./dist/workspace/conformance/index.d.ts",
56
+ "default": "./src/workspace/conformance/index.mjs"
57
+ },
58
+ "./workspace/conformance/vitest": {
59
+ "development": "./src/workspace/conformance/vitest.ts",
60
+ "types": "./dist/workspace/conformance/vitest.d.ts",
61
+ "default": "./src/workspace/conformance/vitest.ts"
62
+ },
53
63
  "./cli/*": {
54
64
  "development": "./src/cli/*.ts",
55
65
  "types": "./dist/cli/*.d.ts",
@@ -104,6 +114,14 @@
104
114
  "devDependencies": {
105
115
  "@cdktn/hcl2json": "^0.24.0"
106
116
  },
117
+ "peerDependencies": {
118
+ "vitest": ">=2"
119
+ },
120
+ "peerDependenciesMeta": {
121
+ "vitest": {
122
+ "optional": true
123
+ }
124
+ },
107
125
  "overrides": {
108
126
  "esbuild": "^0.28.1"
109
127
  }
@@ -619,6 +619,10 @@ async function runComponentGraphView(
619
619
  wave: waveOf.get(name) ?? null,
620
620
  liveNames: graph.liveNames?.[name] ?? [name],
621
621
  ...(graph.composites?.[name] ? { composites: graph.composites[name] } : {}),
622
+ // The archetype (#2662), declared or inferred, so a reader such as
623
+ // `chant workspace graph --composites` labels a component without
624
+ // importing its source.
625
+ ...(graph.archetypes?.[name] ? { archetype: graph.archetypes[name] } : {}),
622
626
  },
623
627
  // Deep-link the node to its `*.component.ts` (behold's inspect panel).
624
628
  ...(graph.files?.[name] ? { sourceLoc: { file: graph.files[name] } } : {}),
@@ -688,6 +688,15 @@ describe("workspace records (#2546)", () => {
688
688
  });
689
689
  });
690
690
 
691
+ describe("workspace graph --composites (#2662)", () => {
692
+ test("parses as a boolean on workspace graph", () => {
693
+ const args = parseArgs(["workspace", "graph", "--composites", "--at", "HEAD", "--json"]);
694
+ expect(args).toMatchObject({ command: "workspace", path: "graph", composites: true, at: "HEAD", json: true });
695
+ expect(() => parseArgs(["workspace", "graph", "--composites=yes"])).toThrow();
696
+ expect(resolveCommand(args, commandRegistry)?.def.name).toBe("workspace graph");
697
+ });
698
+ });
699
+
691
700
  describe("workspace graph --intent (#2651)", () => {
692
701
  test("parses the region and every --kind, in order", () => {
693
702
  const args = parseArgs(["workspace", "graph", "--intent", "app/server.mjs:3-7", "--kind", "a.kind.mjs", "--kind", "b.kind.mjs", "--at", "HEAD", "--json"]);
package/src/cli/main.ts CHANGED
@@ -69,6 +69,7 @@ const BOOLEAN_FLAGS = new Set([
69
69
  "--strict",
70
70
  "--validate",
71
71
  "--use-composites",
72
+ "--composites",
72
73
  "--stacks",
73
74
  "--components",
74
75
  "--up",
@@ -365,12 +366,35 @@ export function parseArgs(args: string[]): ParsedArgs {
365
366
  if (!result.kind || result.kind.startsWith("-")) throw new Error("--kind needs a kind file: --kind <path>");
366
367
  // Repeatable for `workspace graph --intent` (#2651); the others read the last one.
367
368
  (result.kinds ??= []).push(result.kind);
369
+ } else if (arg === "--composites") {
370
+ // `chant workspace graph --composites` (#2662)
371
+ result.composites = true;
368
372
  } else if (arg === "--intent") {
369
373
  // `chant workspace graph --intent <path[:start-end]>` (#2651)
370
374
  result.intent = args[++i];
371
375
  if (!result.intent || result.intent.startsWith("-")) throw new Error("--intent needs a region: --intent <path[:start-end]>");
372
376
  } else if (arg === "--current") {
373
377
  result.current = true;
378
+ } else if (arg === "--set") {
379
+ // `chant workspace records amend <id> --set <file|->` (#2670)
380
+ result.set = args[++i];
381
+ if (!result.set || (result.set.startsWith("-") && result.set !== "-")) throw new Error("--set needs a JSON file, or - for standard input: --set <file|->");
382
+ } else if (arg === "--verdict") {
383
+ // `chant workspace records review <id> --verdict agree|dissent|abstain` (#2670)
384
+ result.verdict = args[++i];
385
+ if (!result.verdict || result.verdict.startsWith("-")) throw new Error("--verdict needs agree, dissent or abstain");
386
+ } else if (arg === "--by") {
387
+ // `chant workspace records review <id> --by <principal>` (#2670): whoever the caller says.
388
+ result.by = args[++i];
389
+ if (!result.by || result.by.startsWith("-")) throw new Error("--by needs the reviewer: --by <principal>");
390
+ } else if (arg === "--session") {
391
+ // `chant workspace records review <id> --session <id>` (#2670)
392
+ result.session = args[++i];
393
+ if (!result.session || result.session.startsWith("-")) throw new Error("--session needs a session id: --session <id>");
394
+ } else if (arg === "--prefix") {
395
+ // `chant workspace records new <kind> --prefix <prefix>` (#2670): the id prefix to allocate under.
396
+ result.prefix = args[++i];
397
+ if (!result.prefix || result.prefix.startsWith("-")) throw new Error("--prefix needs an id prefix: --prefix <prefix>");
374
398
  } else if (arg === "--require") {
375
399
  // `chant workspace records|verify --require attested` (#2547)
376
400
  result.require = args[++i];
@@ -717,8 +741,9 @@ Workspace (level 1, #2524):
717
741
  beside it and marks the members whose digests differ.
718
742
  Read only; never fetches. --json prints the
719
743
  read-contract document
720
- workspace records --kind <kind file> [--current] [--at <rev>] [--base <rev>] [--require attested] [--json]
721
- Read the records a record kind locates, validated
744
+ workspace records [--kind <kind file>] [--current] [--at <rev>] [--base <rev>] [--require attested] [--json]
745
+ Without --kind, every record kind the declaration
746
+ names. Read the records a record kind locates, validated
722
747
  against its schema, with reason codes for invalid
723
748
  ones. --current leaves out superseded records; --at
724
749
  reads a commit's git objects. Needs no workspace file.
@@ -727,9 +752,32 @@ Workspace (level 1, #2524):
727
752
  --require attested exits 2 if any record is not
728
753
  attested. A pinned file that changed is a warning,
729
754
  asset-drift or asset-missing
755
+ workspace records [--kind <kind file>] --since <rev> [--at <rev>] [--json]
756
+ What changed in the records between <rev> and --at
757
+ (default: the working tree): new and removed records,
758
+ state transitions, new verdicts, new supersessions
759
+ and changed pins
730
760
  workspace records pin <path>
731
761
  Print the path from the workspace root and the
732
762
  sha256 of a file, for a decision's evidence pin
763
+ workspace records new [<kind file>] --from <file|-> [--prefix <prefix>] [--dry-run]
764
+ Write one new record in the kind's directory from
765
+ the JSON fields given, after validating them as
766
+ records would read them. Without a kind file, the one
767
+ kind the declaration names. Allocates the next id when
768
+ the fields hold none. Prints {path, id} as JSON and
769
+ never commits
770
+ workspace records amend <id> [--kind <kind file>] --set <file|-> [--dry-run]
771
+ Set top-level fields of one record. A closed record
772
+ never changes, and an approved one changes only its
773
+ state (upward), pins and reviews; anything else is
774
+ refused with amend-supersede-instead. Prints
775
+ {path, id, changed}
776
+ workspace records review <id> [--kind <kind file>] --verdict agree|dissent|abstain --by <principal> [--note <text>] [--session <id>] [--dry-run]
777
+ Append a review to one record, dated and bound to
778
+ the digest of the record text. A dissent needs
779
+ --note. The principal is not checked; attestation is
780
+ the seal's job. Prints {path, id, review}
733
781
  workspace verify [--base <rev>] [--head <rev>] [--require attested]
734
782
  Check the commits in base..head against the signers
735
783
  and roles read from base. A change to the signers file
@@ -773,10 +821,15 @@ Workspace (level 1, #2524):
773
821
  read-contract document. --at <rev> runs each member's
774
822
  source as it was at that commit; --kind adds the
775
823
  records' asset and constrains links
824
+ workspace graph --composites [--at <rev>] [--member <name>] [-o <file>]
825
+ Each composite instance the members declare, with the
826
+ components whose contract can deploy it; an instance
827
+ with none lists an empty set
776
828
  workspace graph --intent <path[:start-end]> [--at <rev>] [--kind <kind file>...] [--json]
777
829
  The intent graph over one region: the commits that
778
830
  touched it, the decisions whose constrains cover it,
779
- the artifacts they pin, and findings with closed codes
831
+ the artifacts they pin, and findings with closed codes.
832
+ Without --kind, every record kind the declaration names
780
833
 
781
834
  Lifecycle (alias: lc):
782
835
  lifecycle snapshot <env> Query API, save metadata to orphan branch
@@ -1,6 +1,8 @@
1
1
  import { resolve, join, dirname } from "node:path";
2
2
  import { readFile } from "node:fs/promises";
3
3
  import type { ResourceDefinition } from "./types";
4
+ import type { LexiconPlugin } from "../../lexicon";
5
+ import { collectComposites } from "./tools/composites";
4
6
  import { getContext } from "./resources/context";
5
7
  import { readSnapshot, readEnvironmentSnapshots } from "../../lifecycle/git";
6
8
  import { discoverOps } from "../../op/discover";
@@ -44,6 +46,12 @@ export const coreResourceDefinitions: ResourceDefinition[] = [
44
46
  description: "Latest run state for a named Op",
45
47
  mimeType: "application/json",
46
48
  },
49
+ {
50
+ uri: "chant://composites",
51
+ name: "Composite catalog",
52
+ description: "Every composite the loaded lexicons export, with what each bundles and its parameters (#2662)",
53
+ mimeType: "application/json",
54
+ },
47
55
  {
48
56
  uri: "chant://knowledge",
49
57
  name: "Knowledge bundle",
@@ -99,9 +107,18 @@ export function collectExamples(
99
107
  export async function handleResourcesRead(
100
108
  params: Record<string, unknown>,
101
109
  pluginResources: Map<string, PluginResourceEntry>,
110
+ plugins: LexiconPlugin[] = [],
102
111
  ): Promise<unknown> {
103
112
  const uri = params.uri as string;
104
113
 
114
+ // The whole composite catalog (#2662), static data from each lexicon's
115
+ // `composites()`; the `composites` tool filters the same list.
116
+ if (uri === "chant://composites") {
117
+ return {
118
+ contents: [{ uri, mimeType: "application/json", text: JSON.stringify(collectComposites(plugins), null, 2) }],
119
+ };
120
+ }
121
+
105
122
  if (uri === "chant://context") {
106
123
  return {
107
124
  contents: [
@@ -778,6 +778,142 @@ describe("McpServer", () => {
778
778
  });
779
779
  });
780
780
 
781
+ // -----------------------------------------------------------------------
782
+ // Composite catalog (#2662)
783
+ // -----------------------------------------------------------------------
784
+
785
+ describe("composite catalog", () => {
786
+ const awsLike = createMockPlugin({
787
+ name: "aws",
788
+ composites: () => [
789
+ {
790
+ name: "LambdaSqs",
791
+ lexicon: "aws",
792
+ description: "A Lambda function fed by an SQS queue through an event source mapping.",
793
+ bundles: ["EventSourceMapping", "Function", "Queue", "Role"],
794
+ params: [{ name: "queueName", type: "Value<string>", required: false }],
795
+ },
796
+ {
797
+ name: "VpcDefault",
798
+ lexicon: "aws",
799
+ description: "A VPC with public and private subnets.",
800
+ bundles: ["Subnet", "Vpc"],
801
+ params: [{ name: "cidr", type: "string", required: false }],
802
+ },
803
+ ],
804
+ mcpResources: () => [
805
+ {
806
+ uri: "resource-catalog",
807
+ name: "Catalog",
808
+ description: "Resource catalog",
809
+ mimeType: "application/json",
810
+ handler: async () => JSON.stringify([
811
+ { className: "Queue", resourceType: "AWS::SQS::Queue", kind: "resource" },
812
+ ]),
813
+ },
814
+ ],
815
+ });
816
+ const k8sLike = createMockPlugin({
817
+ name: "k8s",
818
+ composites: () => [
819
+ {
820
+ name: "WebApp",
821
+ lexicon: "k8s",
822
+ description: "A Deployment and Service.",
823
+ bundles: ["Deployment", "Service"],
824
+ params: [{ name: "image", type: "string", required: true }],
825
+ },
826
+ ],
827
+ });
828
+ const noComposites = createMockPlugin({ name: "cedar" });
829
+
830
+ async function call(s: McpServer, name: string, args: Record<string, unknown>) {
831
+ const response = await s.handleRequest({
832
+ jsonrpc: "2.0",
833
+ id: 1,
834
+ method: "tools/call",
835
+ params: { name, arguments: args },
836
+ });
837
+ return JSON.parse((response.result as { content: Array<{ text: string }> }).content[0].text);
838
+ }
839
+
840
+ test("the composites tool answers what a lexicon has, with bundles and params", async () => {
841
+ const s = new McpServer([awsLike, k8sLike, noComposites]);
842
+ const parsed = await call(s, "composites", { lexicon: "aws" });
843
+ expect(parsed.total).toBe(2);
844
+ expect(parsed.composites.map((c: { name: string }) => c.name)).toEqual(["LambdaSqs", "VpcDefault"]);
845
+ expect(parsed.composites[0].bundles).toContain("Queue");
846
+ expect(parsed.composites[0].params[0].name).toBe("queueName");
847
+ expect(parsed.lexicons).toEqual(["aws", "k8s", "cedar"]);
848
+ });
849
+
850
+ test("with no filter it lists every lexicon's composites; a lexicon with none adds nothing", async () => {
851
+ const s = new McpServer([awsLike, k8sLike, noComposites]);
852
+ const parsed = await call(s, "composites", {});
853
+ expect(parsed.total).toBe(3);
854
+ expect((await call(s, "composites", { lexicon: "cedar" })).total).toBe(0);
855
+ });
856
+
857
+ test("query matches a bundled kind, and a name match ranks first", async () => {
858
+ const s = new McpServer([awsLike, k8sLike]);
859
+ const byKind = await call(s, "composites", { query: "queue" });
860
+ expect(byKind.composites.map((c: { name: string }) => c.name)).toEqual(["LambdaSqs"]);
861
+ const byName = await call(s, "composites", { query: "vpc" });
862
+ expect(byName.composites[0].name).toBe("VpcDefault");
863
+ expect((await call(s, "composites", { limit: 1 })).composites).toHaveLength(1);
864
+ });
865
+
866
+ test("chant://composites lists the whole catalog", async () => {
867
+ const s = new McpServer([awsLike, k8sLike, noComposites]);
868
+ const list = await s.handleRequest({ jsonrpc: "2.0", id: 1, method: "resources/list" });
869
+ const uris = (list.result as { resources: Array<{ uri: string }> }).resources.map((r) => r.uri);
870
+ expect(uris).toContain("chant://composites");
871
+
872
+ const read = await s.handleRequest({
873
+ jsonrpc: "2.0",
874
+ id: 2,
875
+ method: "resources/read",
876
+ params: { uri: "chant://composites" },
877
+ });
878
+ const contents = (read.result as { contents: Array<{ mimeType: string; text: string }> }).contents;
879
+ expect(contents[0].mimeType).toBe("application/json");
880
+ const catalog = JSON.parse(contents[0].text);
881
+ expect(catalog.map((c: { lexicon: string; name: string }) => `${c.lexicon}:${c.name}`)).toEqual([
882
+ "aws:LambdaSqs", "aws:VpcDefault", "k8s:WebApp",
883
+ ]);
884
+ });
885
+
886
+ test("chant://composites is an empty list with no plugins", async () => {
887
+ const read = await new McpServer().handleRequest({
888
+ jsonrpc: "2.0",
889
+ id: 1,
890
+ method: "resources/read",
891
+ params: { uri: "chant://composites" },
892
+ });
893
+ const contents = (read.result as { contents: Array<{ text: string }> }).contents;
894
+ expect(JSON.parse(contents[0].text)).toEqual([]);
895
+ });
896
+
897
+ test("search finds a resource type and the composite that bundles it", async () => {
898
+ const s = new McpServer([awsLike, k8sLike]);
899
+ const parsed = await call(s, "search", { query: "queue" });
900
+ expect(parsed.total).toBe(2);
901
+ const kinds = parsed.results.map((r: { kind: string }) => r.kind).sort();
902
+ expect(kinds).toEqual(["composite", "resource"]);
903
+ const composite = parsed.results.find((r: { kind: string }) => r.kind === "composite");
904
+ expect(composite).toMatchObject({ name: "LambdaSqs", lexicon: "aws", bundles: expect.arrayContaining(["Queue"]) });
905
+ expect(composite.params).toBeUndefined();
906
+ });
907
+
908
+ test("search's lexicon filter applies to composites too", async () => {
909
+ const s = new McpServer([awsLike, k8sLike]);
910
+ const parsed = await call(s, "search", { query: "deployment", lexicon: "aws" });
911
+ expect(parsed.total).toBe(0);
912
+ const k8s = await call(s, "search", { query: "deployment", lexicon: "k8s" });
913
+ expect(k8s.results.map((r: { name: string }) => r.name)).toEqual(["WebApp"]);
914
+ });
915
+ });
916
+
781
917
  // -----------------------------------------------------------------------
782
918
  // Scaffold tool with plugins
783
919
  // -----------------------------------------------------------------------
@@ -1466,23 +1602,23 @@ describe("McpServer", () => {
1466
1602
 
1467
1603
  const toolsRes = await s.handleRequest({ jsonrpc: "2.0", id: 2, method: "tools/list" });
1468
1604
  const tools = (toolsRes.result as { tools: Array<{ name: string }> }).tools;
1469
- expect(tools).toHaveLength(13);
1605
+ expect(tools).toHaveLength(14);
1470
1606
  expect(tools.map((t) => t.name).sort()).toEqual([
1471
- "build", "explain", "import", "lifecycle-diff", "lifecycle-snapshot", "lint",
1607
+ "build", "composites", "explain", "import", "lifecycle-diff", "lifecycle-snapshot", "lint",
1472
1608
  "op-approve", "op-list", "op-report", "op-run", "op-status",
1473
1609
  "scaffold", "search",
1474
1610
  ]);
1475
1611
 
1476
1612
  const resourcesRes = await s.handleRequest({ jsonrpc: "2.0", id: 3, method: "resources/list" });
1477
1613
  const resources = (resourcesRes.result as { resources: Array<{ uri: string }> }).resources;
1478
- expect(resources).toHaveLength(8);
1614
+ expect(resources).toHaveLength(9);
1479
1615
  });
1480
1616
 
1481
1617
  test("server with empty plugins array works", async () => {
1482
1618
  const s = new McpServer([]);
1483
1619
  const toolsRes = await s.handleRequest({ jsonrpc: "2.0", id: 1, method: "tools/list" });
1484
1620
  const tools = (toolsRes.result as { tools: Array<{ name: string }> }).tools;
1485
- expect(tools).toHaveLength(13);
1621
+ expect(tools).toHaveLength(14);
1486
1622
  });
1487
1623
  });
1488
1624
  });
@@ -6,6 +6,7 @@ import { importTool, handleImport } from "./tools/import";
6
6
  import { explainTool, handleExplain } from "./tools/explain";
7
7
  import { scaffoldTool, createScaffoldHandler } from "./tools/scaffold";
8
8
  import { searchTool, createSearchHandler } from "./tools/search";
9
+ import { compositesTool, createCompositesHandler } from "./tools/composites";
9
10
  import type { LexiconPlugin } from "../../lexicon";
10
11
  import type { McpRequest, McpResponse, McpRequestMeta, ToolDefinition, ToolHandler, ResourceDefinition } from "./types";
11
12
  import { createSnapshotTool, createDiffTool } from "./lifecycle-tools";
@@ -90,8 +91,10 @@ export class McpServer {
90
91
  private tools: Map<string, ToolDefinition> = new Map();
91
92
  private toolHandlers: Map<string, ToolHandler> = new Map();
92
93
  private pluginResources: Map<string, { definition: ResourceDefinition; handler: () => Promise<string> }> = new Map();
94
+ private plugins: LexiconPlugin[];
93
95
 
94
96
  constructor(plugins?: LexiconPlugin[]) {
97
+ this.plugins = plugins ?? [];
95
98
  // Register core tools
96
99
  this.registerTool(buildTool, handleBuild);
97
100
  this.registerTool(lintTool, handleLint);
@@ -99,6 +102,7 @@ export class McpServer {
99
102
  this.registerTool(explainTool, handleExplain);
100
103
  this.registerTool(scaffoldTool, createScaffoldHandler(plugins ?? []));
101
104
  this.registerTool(searchTool, createSearchHandler(plugins ?? []));
105
+ this.registerTool(compositesTool, createCompositesHandler(plugins ?? []));
102
106
 
103
107
  // Register state tools
104
108
  const snapshot = createSnapshotTool(plugins ?? []);
@@ -252,7 +256,7 @@ export class McpServer {
252
256
  return buildResourcesList(this.pluginResources);
253
257
 
254
258
  case "resources/read":
255
- return handleResourcesRead(params, this.pluginResources);
259
+ return handleResourcesRead(params, this.pluginResources, this.plugins);
256
260
 
257
261
  default:
258
262
  throw new Error(`Unknown method: ${method}`);
@@ -0,0 +1,98 @@
1
+ import type { CompositeEntry, LexiconPlugin } from "../../../lexicon";
2
+
3
+ /**
4
+ * The `composites` tool (#2662): what composites the loaded lexicons export,
5
+ * what each one bundles and what it takes. "What composites do you have for
6
+ * aws?" is `{ lexicon: "aws" }`.
7
+ *
8
+ * Everything here reads `LexiconPlugin.composites()`, which is static catalog
9
+ * data. No composite is loaded or called and no provider is asked anything.
10
+ */
11
+ export const compositesTool = {
12
+ name: "composites",
13
+ description:
14
+ "List the composites the loaded lexicons export: what each builds, the resource kinds it bundles, and its parameters",
15
+ inputSchema: {
16
+ type: "object" as const,
17
+ properties: {
18
+ lexicon: {
19
+ type: "string",
20
+ description: "Only composites from this lexicon (e.g. 'aws', 'k8s')",
21
+ },
22
+ query: {
23
+ type: "string",
24
+ description: "Keyword matched against the composite's name, description, bundled resource kinds and parameter names",
25
+ },
26
+ limit: {
27
+ type: "number",
28
+ description: "Maximum number of composites to return (default: 50)",
29
+ },
30
+ },
31
+ },
32
+ };
33
+
34
+ /**
35
+ * Every composite the plugins contribute, in plugin order. A plugin whose
36
+ * `composites()` throws contributes nothing rather than failing the listing.
37
+ */
38
+ export function collectComposites(plugins: LexiconPlugin[]): CompositeEntry[] {
39
+ const entries: CompositeEntry[] = [];
40
+ for (const plugin of plugins) {
41
+ let contributed: CompositeEntry[] = [];
42
+ try {
43
+ contributed = plugin.composites?.() ?? [];
44
+ } catch {
45
+ contributed = [];
46
+ }
47
+ for (const entry of contributed) entries.push({ ...entry, lexicon: entry.lexicon || plugin.name });
48
+ }
49
+ return entries;
50
+ }
51
+
52
+ /**
53
+ * How well an entry matches a lowercased keyword: 0 for no match, higher for
54
+ * better. A name match beats a bundled kind, which beats a word in the
55
+ * description or a parameter name, so `queue` lists `LambdaSqs` ahead of a
56
+ * composite that merely mentions queues.
57
+ */
58
+ export function compositeMatchScore(entry: CompositeEntry, lowerQuery: string): number {
59
+ const name = entry.name.toLowerCase();
60
+ if (name.startsWith(lowerQuery)) return 4;
61
+ if (name.includes(lowerQuery)) return 3;
62
+ if (entry.bundles.some((b) => b.toLowerCase().includes(lowerQuery))) return 2;
63
+ if (entry.description.toLowerCase().includes(lowerQuery)) return 1;
64
+ if (entry.params.some((p) => p.name.toLowerCase().includes(lowerQuery))) return 1;
65
+ return 0;
66
+ }
67
+
68
+ export function createCompositesHandler(
69
+ plugins: LexiconPlugin[],
70
+ ): (params: Record<string, unknown>) => Promise<unknown> {
71
+ return async (params) => {
72
+ const lexicon = typeof params.lexicon === "string" && params.lexicon !== "" ? params.lexicon : undefined;
73
+ const query = typeof params.query === "string" && params.query !== "" ? params.query : undefined;
74
+ const limit = typeof params.limit === "number" ? params.limit : 50;
75
+
76
+ let entries = collectComposites(plugins);
77
+ if (lexicon) entries = entries.filter((e) => e.lexicon === lexicon);
78
+
79
+ if (query) {
80
+ const lowerQuery = query.toLowerCase();
81
+ entries = entries
82
+ .map((entry) => ({ entry, score: compositeMatchScore(entry, lowerQuery) }))
83
+ .filter(({ score }) => score > 0)
84
+ .sort((a, b) => b.score - a.score || a.entry.name.localeCompare(b.entry.name))
85
+ .map(({ entry }) => entry);
86
+ }
87
+
88
+ return {
89
+ ...(lexicon ? { lexicon } : {}),
90
+ ...(query ? { query } : {}),
91
+ // The loaded lexicons, so an empty answer for `lexicon: "cedar"` reads
92
+ // as "cedar exports none" when cedar is listed, and "not loaded" when not.
93
+ lexicons: plugins.map((p) => p.name),
94
+ total: entries.length,
95
+ composites: entries.slice(0, limit),
96
+ };
97
+ };
98
+ }
@@ -1,17 +1,20 @@
1
1
  import type { LexiconPlugin } from "../../../lexicon";
2
+ import { collectComposites, compositeMatchScore } from "./composites";
2
3
 
3
4
  /**
4
5
  * Search tool definition for MCP
5
6
  */
6
7
  export const searchTool = {
7
8
  name: "search",
8
- description: "Search the resource catalog across loaded lexicons by keyword",
9
+ description:
10
+ "Search the resource catalog and the composite catalog across loaded lexicons by keyword; a composite comes back with kind 'composite'",
9
11
  inputSchema: {
10
12
  type: "object" as const,
11
13
  properties: {
12
14
  query: {
13
15
  type: "string",
14
- description: "Search query — matches against resource type, class name, and kind",
16
+ description:
17
+ "Search query: matches a resource's type, class name and kind, and a composite's name, description and the resource kinds it bundles",
15
18
  },
16
19
  lexicon: {
17
20
  type: "string",
@@ -32,6 +35,20 @@ interface CatalogEntry {
32
35
  kind?: string;
33
36
  }
34
37
 
38
+ /** A composite in search results (#2662): `kind` is always `"composite"`. */
39
+ interface CompositeResult {
40
+ kind: "composite";
41
+ name: string;
42
+ description: string;
43
+ bundles: string[];
44
+ }
45
+
46
+ type SearchResult = (CatalogEntry | CompositeResult) & { lexicon: string; score: number };
47
+
48
+ function sortKey(result: SearchResult): string {
49
+ return "resourceType" in result ? result.resourceType ?? "" : result.name;
50
+ }
51
+
35
52
  /**
36
53
  * Create a search handler with access to loaded plugins
37
54
  */
@@ -44,7 +61,7 @@ export function createSearchHandler(
44
61
  const limit = (params.limit as number) ?? 20;
45
62
 
46
63
  const lowerQuery = query.toLowerCase();
47
- const results: Array<CatalogEntry & { lexicon: string; score: number }> = [];
64
+ const results: SearchResult[] = [];
48
65
 
49
66
  const candidates = lexiconFilter
50
67
  ? plugins.filter((p) => p.name === lexiconFilter)
@@ -86,10 +103,35 @@ export function createSearchHandler(
86
103
  }
87
104
  }
88
105
 
89
- // Sort: prefix matches first, then alphabetical by resourceType
106
+ // Composites (#2662), so one query finds both a resource type and the
107
+ // composite that bundles it. A name or bundled-kind prefix ranks with the
108
+ // resource prefix matches. The full entry (params included) is the
109
+ // `composites` tool's answer; search keeps a result short.
110
+ for (const entry of collectComposites(candidates)) {
111
+ const score = compositeMatchScore(entry, lowerQuery);
112
+ if (score === 0) continue;
113
+ const isPrefix =
114
+ entry.name.toLowerCase().startsWith(lowerQuery) ||
115
+ entry.bundles.some((b) => b.toLowerCase().startsWith(lowerQuery));
116
+ results.push({
117
+ kind: "composite",
118
+ name: entry.name,
119
+ description: entry.description,
120
+ bundles: entry.bundles,
121
+ lexicon: entry.lexicon,
122
+ score: isPrefix ? 1 : 0,
123
+ });
124
+ }
125
+
126
+ // Sort: prefix matches first; within a tier composites ahead of resource
127
+ // types, since a handful of composites would otherwise sort behind every
128
+ // `AWS::...` type the same word prefixes; then alphabetical.
90
129
  results.sort((a, b) => {
91
130
  if (a.score !== b.score) return b.score - a.score;
92
- return (a.resourceType ?? "").localeCompare(b.resourceType ?? "");
131
+ const aComposite = a.kind === "composite" ? 0 : 1;
132
+ const bComposite = b.kind === "composite" ? 0 : 1;
133
+ if (aComposite !== bComposite) return aComposite - bComposite;
134
+ return sortKey(a).localeCompare(sortKey(b));
93
135
  });
94
136
 
95
137
  const limited = results.slice(0, limit);
@@ -285,10 +285,22 @@ export interface ParsedArgs {
285
285
  kind?: string;
286
286
  /** Every `--kind` given, in order: `chant workspace graph --intent` reads each (#2651). */
287
287
  kinds?: string[];
288
+ /** `chant workspace graph --composites` (#2662): print each composite instance with the components that can deploy it. */
289
+ composites?: boolean;
288
290
  /** `chant workspace graph --intent <path[:start-end]>` (#2651): the region the intent graph is over. */
289
291
  intent?: string;
290
292
  /** `chant workspace records --current` (#2546): leave out records a closed record supersedes. */
291
293
  current?: boolean;
294
+ /** `chant workspace records amend <id> --set <file|->` (#2670): the JSON fields to set. */
295
+ set?: string;
296
+ /** `chant workspace records review <id> --verdict <v>` (#2670): agree, dissent or abstain. */
297
+ verdict?: string;
298
+ /** `chant workspace records review <id> --by <principal>` (#2670): the reviewer, as the caller names them. */
299
+ by?: string;
300
+ /** `chant workspace records review <id> --session <id>` (#2670): the review session the verdict was given in. */
301
+ session?: string;
302
+ /** `chant workspace records new <kind> --prefix <prefix>` (#2670): the id prefix to allocate under. */
303
+ prefix?: string;
292
304
  /** `chant workspace records|verify --require attested` (#2547): the provenance level a gate requires. */
293
305
  require?: string;
294
306
  /**
@@ -414,7 +426,7 @@ export interface ParsedArgs {
414
426
  plan?: string;
415
427
  /** `chant operator log --op <name>` (#2029) — restrict the tick history to one ConvergeOp by name. Omitted, every discovered ConvergeOp's ticks are merged into one timeline. */
416
428
  op?: string;
417
- /** `chant operator log --since <iso>` (#2029) — only entries at or after this ISO-8601 instant. */
429
+ /** `chant operator log --since <iso>` (#2029) — only entries at or after this ISO-8601 instant. `chant workspace records --since <rev>` (#2673) — the revision to compare the records with. */
418
430
  since?: string;
419
431
  /** `chant operator log --limit <n>` (#2029) — keep only the newest n entries (still printed oldest-first). */
420
432
  limit?: number;