@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.
- package/dist/cli/handlers/misc.d.ts.map +1 -1
- package/dist/cli/handlers/serve.d.ts.map +1 -1
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/mcp/server.d.ts +10 -1
- package/dist/cli/mcp/server.d.ts.map +1 -1
- package/dist/cli/mcp/workspace-plugins.d.ts +40 -0
- package/dist/cli/mcp/workspace-plugins.d.ts.map +1 -0
- package/dist/cli/registry.d.ts +6 -1
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/cli/version.d.ts +8 -0
- package/dist/cli/version.d.ts.map +1 -0
- package/dist/workspace/__fixtures__/sessions.d.ts +9 -0
- package/dist/workspace/__fixtures__/sessions.d.ts.map +1 -1
- package/dist/workspace/composites.d.ts +13 -0
- package/dist/workspace/composites.d.ts.map +1 -1
- package/dist/workspace/environments.d.ts +80 -0
- package/dist/workspace/environments.d.ts.map +1 -0
- package/dist/workspace/reason-codes.d.ts +14 -5
- package/dist/workspace/reason-codes.d.ts.map +1 -1
- package/dist/workspace/records-cli.d.ts +6 -2
- package/dist/workspace/records-cli.d.ts.map +1 -1
- package/dist/workspace/records-close.d.ts +41 -0
- package/dist/workspace/records-close.d.ts.map +1 -0
- package/dist/workspace/records-since.d.ts +40 -1
- package/dist/workspace/records-since.d.ts.map +1 -1
- package/dist/workspace/records-write.d.ts +109 -11
- package/dist/workspace/records-write.d.ts.map +1 -1
- package/dist/workspace/records.d.ts +40 -12
- package/dist/workspace/records.d.ts.map +1 -1
- package/dist/workspace/runtimes.d.ts +6 -0
- package/dist/workspace/runtimes.d.ts.map +1 -1
- package/dist/workspace/session-kinds.d.ts +28 -0
- package/dist/workspace/session-kinds.d.ts.map +1 -0
- package/dist/workspace/status.d.ts +2 -0
- package/dist/workspace/status.d.ts.map +1 -1
- package/dist/workspace/trust/seal.d.ts +42 -0
- package/dist/workspace/trust/seal.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/cli/handlers/misc.ts +1 -9
- package/src/cli/handlers/serve.ts +10 -1
- package/src/cli/main.test.ts +7 -0
- package/src/cli/main.ts +60 -9
- package/src/cli/mcp/server.test.ts +14 -1
- package/src/cli/mcp/server.ts +13 -2
- package/src/cli/mcp/workspace-plugins.ts +123 -0
- package/src/cli/registry.ts +6 -1
- package/src/cli/serve-mcp-workspace.test.ts +142 -0
- package/src/cli/version.ts +15 -0
- package/src/workspace/__fixtures__/sessions.ts +41 -0
- package/src/workspace/composites.schema.json +68 -3
- package/src/workspace/composites.test.ts +119 -5
- package/src/workspace/composites.ts +26 -7
- package/src/workspace/conformance/__fixture__/decisions/decision.schema.json +11 -0
- package/src/workspace/environments.ts +165 -0
- package/src/workspace/read-contract.test.ts +3 -0
- package/src/workspace/reason-codes.test.ts +8 -3
- package/src/workspace/reason-codes.ts +18 -6
- package/src/workspace/record-assets.test.ts +4 -3
- package/src/workspace/records-amend.schema.json +30 -1
- package/src/workspace/records-cli.ts +44 -7
- package/src/workspace/records-close.schema.json +192 -0
- package/src/workspace/records-close.ts +129 -0
- package/src/workspace/records-contract.test.ts +4 -3
- package/src/workspace/records-new.schema.json +25 -0
- package/src/workspace/records-review.schema.json +55 -2
- package/src/workspace/records-sessions-write.test.ts +274 -0
- package/src/workspace/records-since.schema.json +27 -2
- package/src/workspace/records-since.ts +120 -6
- package/src/workspace/records-write-contract.test.ts +5 -1
- package/src/workspace/records-write.test.ts +4 -2
- package/src/workspace/records-write.ts +307 -43
- package/src/workspace/records.schema.json +22 -3
- package/src/workspace/records.ts +73 -22
- package/src/workspace/runtimes.ts +12 -3
- package/src/workspace/session-kinds.ts +79 -0
- package/src/workspace/status.ts +1 -1
- package/src/workspace/trust/record-seal.test.ts +315 -0
- package/src/workspace/trust/seal.test.ts +4 -14
- 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.
|
|
774
|
-
|
|
775
|
-
|
|
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.
|
|
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.
|
|
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
|
|
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(
|
|
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 () => {
|
package/src/cli/mcp/server.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/src/cli/registry.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
+
}
|