@intentius/chant 0.87.0 → 0.89.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 (79) hide show
  1. package/dist/cli/handlers/misc.d.ts.map +1 -1
  2. package/dist/cli/handlers/serve.d.ts.map +1 -1
  3. package/dist/cli/main.d.ts.map +1 -1
  4. package/dist/cli/mcp/server.d.ts +10 -1
  5. package/dist/cli/mcp/server.d.ts.map +1 -1
  6. package/dist/cli/mcp/workspace-plugins.d.ts +40 -0
  7. package/dist/cli/mcp/workspace-plugins.d.ts.map +1 -0
  8. package/dist/cli/registry.d.ts +6 -1
  9. package/dist/cli/registry.d.ts.map +1 -1
  10. package/dist/cli/version.d.ts +8 -0
  11. package/dist/cli/version.d.ts.map +1 -0
  12. package/dist/workspace/__fixtures__/sessions.d.ts +9 -0
  13. package/dist/workspace/__fixtures__/sessions.d.ts.map +1 -1
  14. package/dist/workspace/composites.d.ts +13 -0
  15. package/dist/workspace/composites.d.ts.map +1 -1
  16. package/dist/workspace/environments.d.ts +80 -0
  17. package/dist/workspace/environments.d.ts.map +1 -0
  18. package/dist/workspace/reason-codes.d.ts +14 -5
  19. package/dist/workspace/reason-codes.d.ts.map +1 -1
  20. package/dist/workspace/records-cli.d.ts +6 -2
  21. package/dist/workspace/records-cli.d.ts.map +1 -1
  22. package/dist/workspace/records-close.d.ts +41 -0
  23. package/dist/workspace/records-close.d.ts.map +1 -0
  24. package/dist/workspace/records-since.d.ts +40 -1
  25. package/dist/workspace/records-since.d.ts.map +1 -1
  26. package/dist/workspace/records-write.d.ts +109 -11
  27. package/dist/workspace/records-write.d.ts.map +1 -1
  28. package/dist/workspace/records.d.ts +40 -12
  29. package/dist/workspace/records.d.ts.map +1 -1
  30. package/dist/workspace/runtimes.d.ts +6 -0
  31. package/dist/workspace/runtimes.d.ts.map +1 -1
  32. package/dist/workspace/session-kinds.d.ts +28 -0
  33. package/dist/workspace/session-kinds.d.ts.map +1 -0
  34. package/dist/workspace/status.d.ts +2 -0
  35. package/dist/workspace/status.d.ts.map +1 -1
  36. package/dist/workspace/trust/seal.d.ts +42 -0
  37. package/dist/workspace/trust/seal.d.ts.map +1 -1
  38. package/package.json +1 -1
  39. package/src/cli/handlers/misc.ts +1 -9
  40. package/src/cli/handlers/serve.ts +10 -1
  41. package/src/cli/main.test.ts +7 -0
  42. package/src/cli/main.ts +60 -9
  43. package/src/cli/mcp/server.test.ts +14 -1
  44. package/src/cli/mcp/server.ts +13 -2
  45. package/src/cli/mcp/workspace-plugins.ts +123 -0
  46. package/src/cli/registry.ts +6 -1
  47. package/src/cli/serve-mcp-workspace.test.ts +142 -0
  48. package/src/cli/version.ts +15 -0
  49. package/src/workspace/__fixtures__/sessions.ts +41 -0
  50. package/src/workspace/composites.schema.json +68 -3
  51. package/src/workspace/composites.test.ts +119 -5
  52. package/src/workspace/composites.ts +26 -7
  53. package/src/workspace/conformance/__fixture__/decisions/decision.schema.json +11 -0
  54. package/src/workspace/environments.ts +165 -0
  55. package/src/workspace/read-contract.test.ts +3 -0
  56. package/src/workspace/reason-codes.test.ts +8 -3
  57. package/src/workspace/reason-codes.ts +18 -6
  58. package/src/workspace/record-assets.test.ts +4 -3
  59. package/src/workspace/records-amend.schema.json +30 -1
  60. package/src/workspace/records-cli.ts +44 -7
  61. package/src/workspace/records-close.schema.json +192 -0
  62. package/src/workspace/records-close.ts +129 -0
  63. package/src/workspace/records-contract.test.ts +4 -3
  64. package/src/workspace/records-new.schema.json +25 -0
  65. package/src/workspace/records-review.schema.json +55 -2
  66. package/src/workspace/records-sessions-write.test.ts +274 -0
  67. package/src/workspace/records-since.schema.json +27 -2
  68. package/src/workspace/records-since.ts +120 -6
  69. package/src/workspace/records-write-contract.test.ts +5 -1
  70. package/src/workspace/records-write.test.ts +4 -2
  71. package/src/workspace/records-write.ts +307 -43
  72. package/src/workspace/records.schema.json +22 -3
  73. package/src/workspace/records.ts +73 -22
  74. package/src/workspace/runtimes.ts +12 -3
  75. package/src/workspace/session-kinds.ts +79 -0
  76. package/src/workspace/status.ts +1 -1
  77. package/src/workspace/trust/record-seal.test.ts +315 -0
  78. package/src/workspace/trust/seal.test.ts +4 -14
  79. package/src/workspace/trust/seal.ts +119 -25
package/src/cli/main.ts CHANGED
@@ -7,6 +7,8 @@ import { loadPlugins, resolveProjectLexicons } from "./plugins";
7
7
  import { resolveCommand, type CommandDef, type ParsedArgs } from "./registry";
8
8
  import { loadChantConfigUpward } from "../config";
9
9
  import { findProjectRoot, findWorkspaceRoot } from "../project-root";
10
+ import { isNoLexiconDetected } from "../detectLexicon";
11
+ import { CHANT_VERSION } from "./version";
10
12
  import { validateLexiconConfig, formatLexiconConfigProblems } from "../lexicon-config";
11
13
  import { armSandboxConfigEvaluation } from "../config-sandbox";
12
14
  import { armSandboxPolicyExecution } from "../lint/policy-import";
@@ -52,6 +54,7 @@ import type { LexiconPlugin } from "../lexicon";
52
54
  */
53
55
  const BOOLEAN_FLAGS = new Set([
54
56
  "--help",
57
+ "--version",
55
58
  "--agents",
56
59
  "--agent",
57
60
  "--all-projects",
@@ -167,6 +170,8 @@ export function parseArgs(args: string[]): ParsedArgs {
167
170
 
168
171
  if (arg === "--help" || arg === "-h") {
169
172
  result.help = true;
173
+ } else if (arg === "--version" || arg === "-V") {
174
+ result.version = true;
170
175
  } else if (arg === "--output" || arg === "-o") {
171
176
  result.output = args[++i];
172
177
  } else if (arg === "--format" || arg === "-f") {
@@ -389,6 +394,7 @@ export function parseArgs(args: string[]): ParsedArgs {
389
394
  if (!result.by || result.by.startsWith("-")) throw new Error("--by needs the reviewer: --by <principal>");
390
395
  } else if (arg === "--sign") {
391
396
  // `chant workspace records review <id> --sign [<key file>]` (#2687): seal the verdict.
397
+ // `records new` and `records amend` take it too, to seal the record's author (#2688).
392
398
  // With no key file, git's user.signingkey, as `git commit -S` reads it.
393
399
  const next = args[i + 1];
394
400
  result.sign = next !== undefined && !next.startsWith("-") ? args[++i] : true;
@@ -757,34 +763,45 @@ Workspace (level 1, #2524):
757
763
  --require attested exits 2 if any record is not
758
764
  attested. A pinned file that changed is a warning,
759
765
  asset-drift or asset-missing
760
- workspace records [--kind <kind file>] --since <rev> [--at <rev>] [--json]
766
+ workspace records [--kind <kind file>] --since <rev|session id> [--at <rev>] [--json]
761
767
  What changed in the records between <rev> and --at
762
768
  (default: the working tree): new and removed records,
763
769
  state transitions, new verdicts, new supersessions
764
- and changed pins
770
+ and changed pins. A session id compares the commits
771
+ the session opened and closed at
765
772
  workspace records pin <path>
766
773
  Print the path from the workspace root and the
767
774
  sha256 of a file, for a decision's evidence pin
768
- workspace records new [<kind file>] --from <file|-> [--prefix <prefix>] [--dry-run]
775
+ workspace records new [<kind file>] --from <file|-> [--prefix <prefix>] [--sign [<key file>]] [--dry-run]
769
776
  Write one new record in the kind's directory from
770
777
  the JSON fields given, after validating them as
771
778
  records would read them. Without a kind file, the one
772
779
  kind the declaration names. Allocates the next id when
773
- the fields hold none. Prints {path, id} as JSON and
774
- never commits
775
- workspace records amend <id> [--kind <kind file>] --set <file|-> [--dry-run]
780
+ the fields hold none. --sign seals the record's author
781
+ (decided_by for decisions) with an ssh key. Prints
782
+ {path, id} as JSON and never commits
783
+ workspace records amend <id> [--kind <kind file>] --set <file|-> [--sign [<key file>]] [--dry-run]
776
784
  Set top-level fields of one record. A closed record
777
785
  never changes, and an approved one changes only its
778
786
  state (upward), pins and reviews; anything else is
779
- refused with amend-supersede-instead. Prints
787
+ refused with amend-supersede-instead. --sign seals
788
+ the author again; without it an amendment removes
789
+ the author seal and says so. Prints
780
790
  {path, id, changed}
781
791
  workspace records review <id> [--kind <kind file>] --verdict agree|dissent|abstain --by <principal> [--note <text>] [--session <id>] [--sign [<key file>]] [--dry-run]
782
792
  Append a review to one record, dated and bound to
783
793
  the digest of the record text. A dissent needs
784
794
  --note. --sign seals it with an ssh key (git's
785
795
  user.signingkey without a file); under a signers
786
- file at base only a sealed verdict counts. Prints
796
+ file at base only a sealed verdict counts. With
797
+ --session, the session must be open, and the verdict
798
+ is appended to its verdicts too. Prints
787
799
  {path, id, review}
800
+ workspace records close <session id> [--kind <session kind file>] [--dry-run]
801
+ Close an open review session: its state, close time,
802
+ closing commit and seal, in one write. Without
803
+ --kind, the one session kind the declaration names.
804
+ Prints {path, id, changed, seal, closedRev}
788
805
  workspace verify [--base <rev>] [--head <rev>] [--require attested]
789
806
  Check the commits in base..head against the signers
790
807
  and roles read from base. A change to the signers file
@@ -959,6 +976,7 @@ Options:
959
976
  resolved build parameter and per-file fold decision
960
977
  instead of the one-line summaries
961
978
  -h, --help Show this help message
979
+ -V, --version Print the installed chant's version
962
980
  --on <lexicon> Which runtime hosts the run: a configured lexicon with
963
981
  an opRuntime, or the built-in local runtime when
964
982
  omitted (every run subcommand; #2121)
@@ -1171,6 +1189,28 @@ async function loadPluginsOrExit(path: string): Promise<import("../lexicon").Lex
1171
1189
  return plugins;
1172
1190
  }
1173
1191
 
1192
+ /**
1193
+ * #2700 — whether `path` is the root of a declared workspace and holds no
1194
+ * lexicon of its own: no `lexicons` in a root config, and no lexicon import in
1195
+ * the root's source, which leaves the members' directories out (#2527). A
1196
+ * generated chud repo is this shape: its lexicons are all in `delivery/`.
1197
+ * `chant serve mcp` starts there instead of refusing with "No lexicon
1198
+ * detected", and serves core with the chant members' lexicons.
1199
+ *
1200
+ * A directory with no workspace declaration costs only `findWorkspaceRoot`'s
1201
+ * existence checks, so a level-0 project reaches `loadPluginsOrExit` as before.
1202
+ */
1203
+ async function isLexiconlessWorkspaceRoot(path: string): Promise<boolean> {
1204
+ const target = resolve(path);
1205
+ if (findWorkspaceRoot(target)?.dir !== target) return false;
1206
+ try {
1207
+ await resolveProjectLexicons(target);
1208
+ return false;
1209
+ } catch (error) {
1210
+ return isNoLexiconDetected(error);
1211
+ }
1212
+ }
1213
+
1174
1214
  /** Whether `def` must run without evaluating the project's `chant.config.ts` (chant#2591). */
1175
1215
  function commandRunsNoConfig(def: CommandDef, args: ParsedArgs): boolean {
1176
1216
  return typeof def.runsNoConfig === "function" ? def.runsNoConfig(args) : def.runsNoConfig === true;
@@ -1376,6 +1416,15 @@ async function main(): Promise<void> {
1376
1416
  throw err;
1377
1417
  }
1378
1418
 
1419
+ // #2701 — `chant --version` / `-V` prints the installed chant's version, the
1420
+ // one the MCP server reports (./version.ts). With a command word it answers
1421
+ // only for one of core's own commands; a lexicon-mounted verb keeps the flag.
1422
+ if (args.version && (!args.command || resolveCommand(args, commandRegistry))) {
1423
+ console.log(CHANT_VERSION);
1424
+ await flushAndExit(0);
1425
+ return;
1426
+ }
1427
+
1379
1428
  if (args.help || !args.command) {
1380
1429
  const groups = await loadPluginsBestEffort().then(collectCommandGroups).catch(() => []);
1381
1430
  printHelp(groups);
@@ -1503,7 +1552,9 @@ async function main(): Promise<void> {
1503
1552
  const plugins = match.def.requiresPlugins
1504
1553
  ? isGenerateComponents || isComponentsStatus || isEmulator || isFanOutFromFile
1505
1554
  ? await loadPlugins(await resolveProjectLexicons(resolve(projectPath)).catch(() => [])).catch(() => [])
1506
- : await loadPluginsOrExit(projectPath)
1555
+ : match.def.name === "serve mcp" && (await isLexiconlessWorkspaceRoot(projectPath))
1556
+ ? [] // #2700 — runServeMcp loads the chant members' lexicons itself.
1557
+ : await loadPluginsOrExit(projectPath)
1507
1558
  : [];
1508
1559
  const serializers = plugins.map((p) => p.serializer);
1509
1560
  const ctx = { args, plugins, serializers };
@@ -1,11 +1,15 @@
1
1
  import { describe, test, expect, beforeEach, afterEach } from "vitest";
2
2
  import { McpServer } from "./server";
3
+ import { readFileSync } from "node:fs";
3
4
  import { mkdir, rm, writeFile } from "node:fs/promises";
4
5
  import { join } from "node:path";
5
6
  import { tmpdir } from "node:os";
6
7
  import type { LexiconPlugin } from "../../lexicon";
7
8
  import type { Serializer } from "../../serializer";
8
9
 
10
+ /** The version in `packages/core/package.json`, read here apart from the code under test. */
11
+ const CORE_VERSION: string = JSON.parse(readFileSync(join(import.meta.dirname, "..", "..", "..", "package.json"), "utf-8")).version;
12
+
9
13
  function createMockPlugin(overrides?: Partial<LexiconPlugin>): LexiconPlugin {
10
14
  return {
11
15
  name: "mock",
@@ -51,7 +55,16 @@ describe("McpServer", () => {
51
55
  expect(result.protocolVersion).toBe("2026-07-28");
52
56
  expect(result.capabilities).toBeDefined();
53
57
  expect((result.serverInfo as Record<string, unknown>).name).toBe("chant");
54
- expect((result.serverInfo as Record<string, unknown>).version).toBe("0.1.0");
58
+ expect((result.serverInfo as Record<string, unknown>).version).toBe(CORE_VERSION);
59
+ });
60
+
61
+ test("server info names the installed chant's version, from core's package.json (#2689)", async () => {
62
+ expect(CORE_VERSION).toMatch(/^\d+\.\d+\.\d+/);
63
+ expect(CORE_VERSION).not.toBe("0.1.0");
64
+ for (const params of [{}, { protocolVersion: "2024-11-05" }]) {
65
+ const response = await server.handleRequest({ jsonrpc: "2.0", id: 1, method: "initialize", params });
66
+ expect(((response.result as Record<string, unknown>).serverInfo as Record<string, unknown>).version).toBe(CORE_VERSION);
67
+ }
55
68
  });
56
69
 
57
70
  test("capabilities include tools and resources", async () => {
@@ -13,6 +13,7 @@ import { createSnapshotTool, createDiffTool } from "./lifecycle-tools";
13
13
  import { setGateOrigin } from "../../lifecycle/gate-origin";
14
14
  import { createOpListTool, createOpRunTool, createOpStatusTool, createOpApproveTool, createOpReportTool } from "./op-tools";
15
15
  import { buildResourcesList, handleResourcesRead } from "./resource-handlers";
16
+ import { CHANT_VERSION } from "../version";
16
17
 
17
18
  /**
18
19
  * Protocol versions this server understands, newest first. `initialize` and
@@ -92,9 +93,17 @@ export class McpServer {
92
93
  private toolHandlers: Map<string, ToolHandler> = new Map();
93
94
  private pluginResources: Map<string, { definition: ResourceDefinition; handler: () => Promise<string> }> = new Map();
94
95
  private plugins: LexiconPlugin[];
96
+ private instructions: string | undefined;
95
97
 
96
- constructor(plugins?: LexiconPlugin[]) {
98
+ /**
99
+ * `options.instructions` is sent as the `initialize` result's
100
+ * `instructions`, the text a client may give its model about this server.
101
+ * Only a workspace root with no lexicon of its own sets it (#2700), to say
102
+ * which members' lexicons were loaded.
103
+ */
104
+ constructor(plugins?: LexiconPlugin[], options: { instructions?: string } = {}) {
97
105
  this.plugins = plugins ?? [];
106
+ this.instructions = options.instructions;
98
107
  // Register core tools
99
108
  this.registerTool(buildTool, handleBuild);
100
109
  this.registerTool(lintTool, handleLint);
@@ -223,7 +232,9 @@ export class McpServer {
223
232
  return {
224
233
  protocolVersion: negotiateProtocolVersion(protocolVersion),
225
234
  capabilities: { tools: {}, resources: {} },
226
- serverInfo: { name: "chant", version: "0.1.0" },
235
+ // The installed chant's version, so a client can tell which chant it talks to (#2689).
236
+ serverInfo: { name: "chant", version: CHANT_VERSION },
237
+ ...(this.instructions ? { instructions: this.instructions } : {}),
227
238
  };
228
239
  }
229
240
 
@@ -0,0 +1,123 @@
1
+ /**
2
+ * #2700 — the lexicons `chant serve mcp` loads at the root of a declared
3
+ * workspace that has no lexicon of its own.
4
+ *
5
+ * Such a root (a generated chud repo, say, whose lexicons are all in
6
+ * `delivery/`) used to refuse with "No lexicon detected", so an agent started
7
+ * there got none of chant's tools. The server now starts with core's tools and
8
+ * resources, and with the lexicons of the workspace's members of kind chant,
9
+ * each read from that member's own config the way `chant build` in the
10
+ * member's directory reads it.
11
+ *
12
+ * Loading is best effort, one member and one lexicon at a time: a member whose
13
+ * config does not load, or a lexicon this chant cannot import, is left out and
14
+ * named in the server's `instructions`, and the rest are served. Nothing here
15
+ * fails the server; with nothing loaded it serves core alone and says so.
16
+ */
17
+
18
+ import { existsSync } from "node:fs";
19
+ import { join } from "node:path";
20
+ import type { LexiconPlugin } from "../../lexicon";
21
+
22
+ /** One member of kind chant, and what came of reading its lexicons. */
23
+ export interface MemberLexicons {
24
+ member: string;
25
+ dir: string;
26
+ /** The lexicon names its config declares or its source imports, or null when they could not be read. */
27
+ lexicons: string[] | null;
28
+ /** Why they could not be read. */
29
+ error?: string;
30
+ }
31
+
32
+ export interface WorkspacePlugins {
33
+ plugins: LexiconPlugin[];
34
+ members: MemberLexicons[];
35
+ /** Lexicons named by a member that did not load, with the reason. */
36
+ failed: { lexicon: string; error: string }[];
37
+ /** The text the MCP server gives as its `instructions`. */
38
+ instructions: string;
39
+ }
40
+
41
+ function message(error: unknown): string {
42
+ const text = error instanceof Error ? error.message : String(error);
43
+ return text.split("\n")[0];
44
+ }
45
+
46
+ /** Load the lexicons of the chant members of the workspace declared at `root`. */
47
+ export async function loadWorkspacePlugins(root: string): Promise<WorkspacePlugins> {
48
+ const [{ readDeclaration }, { workingTree }, { resolveProjectLexicons, loadPlugins }] = await Promise.all([
49
+ import("../../workspace/declaration"),
50
+ import("../../workspace/tree"),
51
+ import("../plugins"),
52
+ ]);
53
+
54
+ const members: MemberLexicons[] = [];
55
+ let workspaceName: string | undefined;
56
+ let declarationError: string | undefined;
57
+ try {
58
+ const declaration = readDeclaration(workingTree(root));
59
+ workspaceName = declaration.name;
60
+ for (const m of declaration.members) {
61
+ // The root member is the directory this server already found no lexicon in.
62
+ if (m.kind !== "chant" || m.dir === ".") continue;
63
+ const abs = join(root, m.dir);
64
+ if (!existsSync(abs)) {
65
+ members.push({ member: m.name, dir: m.dir, lexicons: null, error: "its directory does not exist" });
66
+ continue;
67
+ }
68
+ try {
69
+ members.push({ member: m.name, dir: m.dir, lexicons: await resolveProjectLexicons(abs) });
70
+ } catch (error) {
71
+ members.push({ member: m.name, dir: m.dir, lexicons: null, error: message(error) });
72
+ }
73
+ }
74
+ } catch (error) {
75
+ declarationError = message(error);
76
+ }
77
+
78
+ const plugins: LexiconPlugin[] = [];
79
+ const failed: { lexicon: string; error: string }[] = [];
80
+ const seen = new Set<string>();
81
+ for (const m of members) {
82
+ for (const name of m.lexicons ?? []) {
83
+ if (seen.has(name)) continue;
84
+ seen.add(name);
85
+ try {
86
+ plugins.push(...(await loadPlugins([name])));
87
+ } catch (error) {
88
+ failed.push({ lexicon: name, error: message(error) });
89
+ }
90
+ }
91
+ }
92
+
93
+ return { plugins, members, failed, instructions: describe(workspaceName, members, plugins, failed, declarationError) };
94
+ }
95
+
96
+ function describe(
97
+ name: string | undefined,
98
+ members: MemberLexicons[],
99
+ plugins: LexiconPlugin[],
100
+ failed: { lexicon: string; error: string }[],
101
+ declarationError: string | undefined,
102
+ ): string {
103
+ const lines: string[] = [];
104
+ lines.push(
105
+ `This chant MCP server runs at the root of the workspace ${name ? `"${name}" ` : ""}(chant.workspace.json), which has no lexicon of its own. ` +
106
+ "Core tools and resources are served as in any project. The tools that take a path (build, lint, explain) work on a project directory, so pass a member's directory.",
107
+ );
108
+ if (declarationError) lines.push(`The workspace declaration could not be read: ${declarationError}.`);
109
+ for (const m of members) {
110
+ lines.push(
111
+ m.lexicons
112
+ ? `Member ${m.member} (${m.dir}/) declares ${m.lexicons.length ? m.lexicons.join(", ") : "no lexicon"}.`
113
+ : `Member ${m.member} (${m.dir}/) was not read: ${m.error}.`,
114
+ );
115
+ }
116
+ for (const f of failed) lines.push(`Lexicon ${f.lexicon} did not load: ${f.error}.`);
117
+ lines.push(
118
+ plugins.length > 0
119
+ ? `Lexicon tools and resources served: ${plugins.map((p) => p.name).join(", ")}.`
120
+ : "No member lexicon loaded, so only chant's core tools and resources are served.",
121
+ );
122
+ return lines.join("\n");
123
+ }
@@ -18,6 +18,8 @@ export interface ParsedArgs {
18
18
  watch: boolean;
19
19
  verbose: boolean;
20
20
  help: boolean;
21
+ /** `--version` / `-V` (#2701): print the installed chant's version. */
22
+ version?: boolean;
21
23
  report?: boolean;
22
24
  /** `chant run` — force the local in-process executor (the default). */
23
25
  local?: boolean;
@@ -297,7 +299,10 @@ export interface ParsedArgs {
297
299
  verdict?: string;
298
300
  /** `chant workspace records review <id> --by <principal>` (#2670): the reviewer, as the caller names them. */
299
301
  by?: string;
300
- /** `chant workspace records review <id> --sign [<key file>]` (#2687): the key that seals the verdict, or true for git's user.signingkey. */
302
+ /**
303
+ * `chant workspace records review <id> --sign [<key file>]` (#2687): the key that seals the verdict, or true for git's user.signingkey.
304
+ * On `records new` and `records amend`, the key that seals the record's author (#2688).
305
+ */
301
306
  sign?: string | true;
302
307
  /** `chant workspace records review <id> --session <id>` (#2670): the review session the verdict was given in. */
303
308
  session?: string;
@@ -0,0 +1,142 @@
1
+ import { describe, test, expect, beforeAll, afterAll } from "vitest";
2
+ import { spawnSync } from "node:child_process";
3
+ import { mkdtempSync, mkdirSync, writeFileSync, rmSync, readFileSync, realpathSync } from "node:fs";
4
+ import { join, resolve } from "node:path";
5
+ import { tmpdir } from "node:os";
6
+ import { pathToFileURL } from "node:url";
7
+
8
+ /**
9
+ * #2700 — `chant serve mcp` at a workspace root whose lexicons all live in
10
+ * members starts, with core's tools and the chant members' lexicons, instead
11
+ * of refusing with "No lexicon detected". A project with no workspace and no
12
+ * lexicon still refuses.
13
+ *
14
+ * #2701 — `chant --version` and `-V` print the installed version.
15
+ */
16
+
17
+ const repoRoot = resolve(import.meta.dirname, "../../../..");
18
+ const mainTs = join(repoRoot, "packages/core/src/cli/main.ts");
19
+ const tsxLoader = pathToFileURL(join(repoRoot, "node_modules/tsx/dist/loader.mjs")).href;
20
+ const TIMEOUT = 90_000;
21
+
22
+ function chant(cwd: string, args: string[], input?: string) {
23
+ return spawnSync(process.execPath, ["--import", tsxLoader, mainTs, ...args], {
24
+ cwd,
25
+ input,
26
+ encoding: "utf-8",
27
+ timeout: 60_000,
28
+ env: { ...process.env, NO_COLOR: "1" },
29
+ });
30
+ }
31
+
32
+ interface InitializeResult {
33
+ serverInfo: { name: string; version: string };
34
+ instructions?: string;
35
+ }
36
+
37
+ /** Send initialize and tools/list over stdio; stdin closing ends the server. */
38
+ function initializeAndList(cwd: string): { status: number | null; stderr: string; init: InitializeResult; tools: string[] } {
39
+ const input = [
40
+ { jsonrpc: "2.0", id: 1, method: "initialize", params: { protocolVersion: "2024-11-05", clientInfo: { name: "test" } } },
41
+ { jsonrpc: "2.0", method: "notifications/initialized" },
42
+ { jsonrpc: "2.0", id: 2, method: "tools/list", params: {} },
43
+ ].map((m) => JSON.stringify(m)).join("\n") + "\n";
44
+ const run = chant(cwd, ["serve", "mcp"], input);
45
+ const responses = run.stdout.trim().split("\n").filter(Boolean).map((l) => JSON.parse(l));
46
+ const byId = (id: number) => responses.find((r) => r.id === id);
47
+ return {
48
+ status: run.status,
49
+ stderr: run.stderr,
50
+ init: byId(1)?.result,
51
+ tools: (byId(2)?.result?.tools ?? []).map((t: { name: string }) => t.name),
52
+ };
53
+ }
54
+
55
+ const CORE_TOOLS = ["composites", "search", "build", "lint", "explain", "op-list", "op-run", "op-status", "op-approve", "op-report"];
56
+
57
+ describe("chant serve mcp at a workspace root with no lexicon of its own (#2700)", () => {
58
+ let scratch: string;
59
+
60
+ beforeAll(() => {
61
+ scratch = realpathSync(mkdtempSync(join(tmpdir(), "chant-serve-mcp-ws-")));
62
+ });
63
+ afterAll(() => {
64
+ rmSync(scratch, { recursive: true, force: true });
65
+ });
66
+
67
+ test("the reference workspace root answers initialize and lists core tools with the delivery member's lexicon", () => {
68
+ const { status, stderr, init, tools } = initializeAndList(join(repoRoot, "reference-workspace"));
69
+ expect(status, stderr).toBe(0);
70
+ expect(init.serverInfo.name).toBe("chant");
71
+ for (const name of CORE_TOOLS) expect(tools).toContain(name);
72
+ expect(tools).toContain("docker:diff");
73
+ expect(init.instructions).toContain("Member delivery (delivery/) declares docker.");
74
+ }, TIMEOUT);
75
+
76
+ test("a chud-shaped root, lexicons only in a member, serves core and the lexicons that load", () => {
77
+ const root = join(scratch, "chud-shaped");
78
+ mkdirSync(join(root, ".git"), { recursive: true });
79
+ mkdirSync(join(root, "delivery"), { recursive: true });
80
+ mkdirSync(join(root, "app"), { recursive: true });
81
+ writeFileSync(join(root, "chant.workspace.json"), JSON.stringify({
82
+ name: "t",
83
+ schema: 1,
84
+ members: [
85
+ { name: "app", dir: "app", kind: "other", because: "an app" },
86
+ { name: "delivery", dir: "delivery", kind: "chant" },
87
+ ],
88
+ pins: [],
89
+ }));
90
+ // `chud` is not installed here, as it would not be for a chant without
91
+ // @intentius/chud: it is named as not loaded and the rest is served.
92
+ writeFileSync(join(root, "delivery", "chant.config.ts"), 'export default { lexicons: ["docker", "chud"] };\n');
93
+ writeFileSync(join(root, "app", "server.js"), "// not chant\n");
94
+
95
+ const { status, stderr, init, tools } = initializeAndList(root);
96
+ expect(status, stderr).toBe(0);
97
+ for (const name of CORE_TOOLS) expect(tools).toContain(name);
98
+ expect(tools).toContain("docker:diff");
99
+ expect(init.instructions).toContain('workspace "t"');
100
+ expect(init.instructions).toContain("Lexicon chud did not load");
101
+ expect(init.instructions).toContain("Lexicon tools and resources served: docker.");
102
+ }, TIMEOUT);
103
+
104
+ test("a workspace root whose members' lexicons all fail to load serves core alone and says so", () => {
105
+ const root = join(scratch, "core-only");
106
+ mkdirSync(join(root, ".git"), { recursive: true });
107
+ mkdirSync(join(root, "delivery"), { recursive: true });
108
+ writeFileSync(join(root, "chant.workspace.json"), JSON.stringify({
109
+ name: "core-only",
110
+ schema: 1,
111
+ members: [{ name: "delivery", dir: "delivery", kind: "chant" }],
112
+ pins: [],
113
+ }));
114
+ writeFileSync(join(root, "delivery", "chant.config.ts"), 'export default { lexicons: ["no-such-lexicon"] };\n');
115
+
116
+ const { status, stderr, init, tools } = initializeAndList(root);
117
+ expect(status, stderr).toBe(0);
118
+ for (const name of CORE_TOOLS) expect(tools).toContain(name);
119
+ expect(tools.filter((t) => t.includes(":"))).toEqual([]);
120
+ expect(init.instructions).toContain("only chant's core tools and resources are served");
121
+ }, TIMEOUT);
122
+
123
+ test("a project with no workspace and no lexicon still refuses", () => {
124
+ const root = join(scratch, "plain");
125
+ mkdirSync(join(root, ".git"), { recursive: true });
126
+ writeFileSync(join(root, "index.ts"), "export const x = 1;\n");
127
+ const run = chant(root, ["serve", "mcp"], "");
128
+ expect(run.status).toBe(1);
129
+ expect(run.stderr).toContain("No lexicon detected");
130
+ expect(run.stdout).toBe("");
131
+ }, TIMEOUT);
132
+ });
133
+
134
+ describe("chant --version (#2701)", () => {
135
+ const version = (JSON.parse(readFileSync(join(repoRoot, "packages/core/package.json"), "utf-8")) as { version: string }).version;
136
+
137
+ test.each([["--version"], ["-V"]])("%s prints the package version and exits 0", (flag) => {
138
+ const run = chant(repoRoot, [flag]);
139
+ expect(run.status, run.stderr).toBe(0);
140
+ expect(run.stdout.trim()).toBe(version);
141
+ }, TIMEOUT);
142
+ });
@@ -0,0 +1,15 @@
1
+ import { createRequire } from "node:module";
2
+
3
+ /**
4
+ * The installed chant's version, read from `@intentius/chant`'s own
5
+ * package.json, or "0.0.0" when it can't be read. The path is the same from
6
+ * `src/cli/` and `dist/cli/`, so it holds whether chant runs from source or
7
+ * from its build.
8
+ */
9
+ export const CHANT_VERSION: string = (() => {
10
+ try {
11
+ return (createRequire(import.meta.url)("../../package.json") as { version?: string }).version ?? "0.0.0";
12
+ } catch {
13
+ return "0.0.0";
14
+ }
15
+ })();
@@ -64,3 +64,44 @@ export function reviewed(root: string, reviewers: string[], session: string, sta
64
64
  .replace(/^reviews: .*$/m, reviewers.length ? `reviews:\n${reviews}` : "reviews: []"),
65
65
  );
66
66
  }
67
+
68
+ /**
69
+ * Declare the fixture's two kinds in a chant.workspace.json (#2693): the
70
+ * decisions at the root and the session kind in the design member, as the
71
+ * reference workspace declares them, so review --session, close and
72
+ * --since <session id> find the session kind.
73
+ */
74
+ export function declareSessions(root: string): void {
75
+ writeFileSync(
76
+ join(root, "chant.workspace.json"),
77
+ `${JSON.stringify(
78
+ {
79
+ name: "sessions",
80
+ schema: 1,
81
+ members: [{ name: "design", dir: "design", kind: "other", because: "the session fixture", records: [{ kind: "sessions/session.kind.mjs" }] }],
82
+ records: [{ kind: DECISIONS_KIND }],
83
+ pins: [],
84
+ },
85
+ null,
86
+ 2,
87
+ )}\n`,
88
+ );
89
+ }
90
+
91
+ /** The fields of a new open session, as a UI sends them to records new. */
92
+ export function newSessionFields(over: Record<string, unknown> = {}): string {
93
+ return JSON.stringify({
94
+ schema: 1,
95
+ title: "Second walk",
96
+ state: "open",
97
+ agenda: [{ record: "ref-001" }, { record: "ref-002" }],
98
+ attendance: [
99
+ { principal: "lex00", class: "person" },
100
+ { principal: "alice", class: "person" },
101
+ ],
102
+ opened: "2026-09-25T09:00:00Z",
103
+ closed: null,
104
+ verdicts: [],
105
+ ...over,
106
+ });
107
+ }