@intentius/chant 0.88.0 → 0.90.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 (52) hide show
  1. package/dist/cli/handlers/serve.d.ts.map +1 -1
  2. package/dist/cli/main.d.ts.map +1 -1
  3. package/dist/cli/mcp/server.d.ts +20 -6
  4. package/dist/cli/mcp/server.d.ts.map +1 -1
  5. package/dist/cli/mcp/types.d.ts +13 -5
  6. package/dist/cli/mcp/types.d.ts.map +1 -1
  7. package/dist/cli/mcp/workspace-plugins.d.ts +40 -0
  8. package/dist/cli/mcp/workspace-plugins.d.ts.map +1 -0
  9. package/dist/cli/mcp/workspace-tools.d.ts +53 -0
  10. package/dist/cli/mcp/workspace-tools.d.ts.map +1 -0
  11. package/dist/cli/registry.d.ts +2 -0
  12. package/dist/cli/registry.d.ts.map +1 -1
  13. package/dist/op/op-verb-class.d.ts.map +1 -1
  14. package/dist/workspace/conformance/index.d.ts +42 -2
  15. package/dist/workspace/conformance/index.d.ts.map +1 -1
  16. package/dist/workspace/conformance/vitest.d.ts.map +1 -1
  17. package/dist/workspace/reason-codes.d.ts +3 -0
  18. package/dist/workspace/reason-codes.d.ts.map +1 -1
  19. package/dist/workspace/records-write.d.ts +35 -3
  20. package/dist/workspace/records-write.d.ts.map +1 -1
  21. package/dist/workspace/records.d.ts +4 -1
  22. package/dist/workspace/records.d.ts.map +1 -1
  23. package/dist/workspace/source-block.d.ts +85 -0
  24. package/dist/workspace/source-block.d.ts.map +1 -0
  25. package/package.json +1 -1
  26. package/src/cli/handlers/serve.ts +11 -1
  27. package/src/cli/main.test.ts +7 -0
  28. package/src/cli/main.ts +40 -1
  29. package/src/cli/mcp/docs-parity.test.ts +20 -2
  30. package/src/cli/mcp/server.ts +32 -6
  31. package/src/cli/mcp/types.ts +15 -2
  32. package/src/cli/mcp/workspace-plugins.ts +123 -0
  33. package/src/cli/mcp/workspace-tools.test.ts +198 -0
  34. package/src/cli/mcp/workspace-tools.ts +405 -0
  35. package/src/cli/registry.ts +2 -0
  36. package/src/cli/serve-mcp-workspace.test.ts +142 -0
  37. package/src/op/op-verb-class.ts +6 -0
  38. package/src/workspace/conformance/__fixture__/decisions/decision.kind.mjs +4 -0
  39. package/src/workspace/conformance/__fixture__/decisions/decision.schema.json +100 -6
  40. package/src/workspace/conformance/index.mjs +3 -0
  41. package/src/workspace/conformance/index.ts +185 -7
  42. package/src/workspace/conformance/vitest.ts +17 -8
  43. package/src/workspace/reason-codes.ts +3 -0
  44. package/src/workspace/records-amend.schema.json +2 -1
  45. package/src/workspace/records-close.schema.json +2 -1
  46. package/src/workspace/records-new.schema.json +4 -1
  47. package/src/workspace/records-review.schema.json +2 -1
  48. package/src/workspace/records-write.ts +85 -7
  49. package/src/workspace/records.schema.json +1 -0
  50. package/src/workspace/records.ts +28 -0
  51. package/src/workspace/source-block.test.ts +167 -0
  52. package/src/workspace/source-block.ts +129 -0
@@ -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
+ });
@@ -47,6 +47,10 @@ const READ_ONLY_ACTIVITY_FNS: ReadonlySet<string> = new Set([
47
47
  "spriteListDir",
48
48
  "listCheckpoints",
49
49
  "convergeTick",
50
+ "spriteUrl",
51
+ "spriteServiceGet",
52
+ "spriteServiceList",
53
+ "spriteServiceLogs",
50
54
  ]);
51
55
 
52
56
  /** Activity function names that always delete/destroy, regardless of args. */
@@ -57,6 +61,8 @@ const ALWAYS_DESTRUCTIVE_ACTIVITY_FNS: ReadonlySet<string> = new Set([
57
61
  "awsDelete",
58
62
  "gcpDelete",
59
63
  "spriteDestroy",
64
+ "spriteDelete",
65
+ "spriteServiceDelete",
60
66
  "k3dDown",
61
67
  "k3sUninstall",
62
68
  ]);
@@ -29,4 +29,8 @@ export const recordKind = {
29
29
  // Verdicts, and the field naming the decider, for each record's digest and
30
30
  // quorum (#2671, #2672).
31
31
  reviews: { field: "reviews", decider: "decided_by" },
32
+ // source is where a decision came from, and says where its proposal came
33
+ // from too: via, client, harness, model, session, turns and a transcript
34
+ // pinned by hash (#2708).
35
+ source: { field: "source" },
32
36
  };
@@ -50,8 +50,8 @@
50
50
  "type": ["string", "null"]
51
51
  },
52
52
  "source": {
53
- "description": "Where the decision was first recorded: a row of an issue's decisions table, or the workspace itself when no issue holds the decision (#2654).",
54
- "oneOf": [{ "$ref": "#/definitions/sourceIssue" }, { "$ref": "#/definitions/sourceWorkspace" }]
53
+ "description": "Where the decision was first recorded: a row of an issue's decisions table, the workspace itself when no issue holds the decision (#2654), or a harness session when neither does (#2708). Each form may also say where the proposal came from: the fields via, client, harness, model, session, turns and transcript, which the kind opts in to with source: { field: \"source\" }.",
54
+ "oneOf": [{ "$ref": "#/definitions/sourceIssue" }, { "$ref": "#/definitions/sourceWorkspace" }, { "$ref": "#/definitions/sourceHarness" }]
55
55
  },
56
56
  "question": {
57
57
  "description": "One sentence. Null only in an importer draft.",
@@ -319,7 +319,14 @@
319
319
  "revision": {
320
320
  "description": "The design revision that last changed this row, such as \"v8\". Null when the row carries no marker.",
321
321
  "type": ["string", "null"]
322
- }
322
+ },
323
+ "session": { "$ref": "#/definitions/sourceSession" },
324
+ "via": { "$ref": "#/definitions/sourceVia" },
325
+ "client": { "$ref": "#/definitions/sourceClient" },
326
+ "harness": { "$ref": "#/definitions/sourceHarnessId" },
327
+ "model": { "$ref": "#/definitions/sourceModel" },
328
+ "turns": { "$ref": "#/definitions/sourceTurns" },
329
+ "transcript": { "$ref": "#/definitions/sourceTranscript" }
323
330
  }
324
331
  },
325
332
  "sourceWorkspace": {
@@ -335,16 +342,103 @@
335
342
  "pattern": "^[a-z0-9][a-z0-9-]{0,39}$"
336
343
  },
337
344
  "session": {
338
- "description": "The id of the session the decision was made in, as the member names it. Null or absent when there was none.",
339
- "type": ["string", "null"],
340
- "minLength": 1
345
+ "description": "The id of the session the decision was made in, as the member names it, or the harness's session with the chant session record it was held in. Null or absent when there was none.",
346
+ "$ref": "#/definitions/sourceSession"
341
347
  },
342
348
  "issue": {
343
349
  "description": "An issue the decision relates to, when there is one.",
344
350
  "$ref": "#/definitions/issueRef"
351
+ },
352
+ "via": { "$ref": "#/definitions/sourceVia" },
353
+ "client": { "$ref": "#/definitions/sourceClient" },
354
+ "harness": { "$ref": "#/definitions/sourceHarnessId" },
355
+ "model": { "$ref": "#/definitions/sourceModel" },
356
+ "turns": { "$ref": "#/definitions/sourceTurns" },
357
+ "transcript": { "$ref": "#/definitions/sourceTranscript" }
358
+ }
359
+ },
360
+ "sourceHarness": {
361
+ "description": "A decision proposed in a harness session, with no issue row or workspace member behind it (#2708). It says how it arrived, in via, and may say the rest of where the proposal came from.",
362
+ "type": "object",
363
+ "additionalProperties": false,
364
+ "required": ["via"],
365
+ "properties": {
366
+ "issue": {
367
+ "description": "An issue the decision relates to, when there is one.",
368
+ "$ref": "#/definitions/issueRef"
369
+ },
370
+ "session": { "$ref": "#/definitions/sourceSession" },
371
+ "via": { "$ref": "#/definitions/sourceVia" },
372
+ "client": { "$ref": "#/definitions/sourceClient" },
373
+ "harness": { "$ref": "#/definitions/sourceHarnessId" },
374
+ "model": { "$ref": "#/definitions/sourceModel" },
375
+ "turns": { "$ref": "#/definitions/sourceTurns" },
376
+ "transcript": { "$ref": "#/definitions/sourceTranscript" }
377
+ }
378
+ },
379
+ "sourceVia": {
380
+ "description": "How the record reached the workspace: cli, a person or script at chant workspace records new; mcp, a harness through chant serve mcp, which fills this and client itself; harvest, a transcript read after the fact. A harvested decision is written proposed, and a person decides it. Data about the proposal, not trust.",
381
+ "enum": ["cli", "mcp", "harvest"]
382
+ },
383
+ "sourceClient": {
384
+ "description": "What wrote the record: the MCP client's clientInfo, or the CLI.",
385
+ "type": "object",
386
+ "additionalProperties": false,
387
+ "required": ["name"],
388
+ "properties": {
389
+ "name": { "type": "string", "minLength": 1 },
390
+ "version": { "type": "string", "minLength": 1 },
391
+ "title": { "type": "string", "minLength": 1 }
392
+ }
393
+ },
394
+ "sourceHarnessId": {
395
+ "description": "The harness the decision was proposed in, such as claude-code, codex, gemini-cli, opencode, fountain or hud.",
396
+ "type": "string",
397
+ "minLength": 1
398
+ },
399
+ "sourceModel": {
400
+ "description": "The model id the harness reports.",
401
+ "type": "string",
402
+ "minLength": 1
403
+ },
404
+ "sourceSession": {
405
+ "description": "The session the decision was made in: its id as a string, or an object with the harness's session or conversation id and the chant session record it was held in (#2697). Null when there was none.",
406
+ "oneOf": [
407
+ { "type": "string", "minLength": 1 },
408
+ { "type": "null" },
409
+ {
410
+ "type": "object",
411
+ "additionalProperties": false,
412
+ "minProperties": 1,
413
+ "properties": {
414
+ "id": { "description": "The harness's session or conversation id.", "type": "string", "minLength": 1 },
415
+ "record": { "description": "The id of the chant session record the decision was held in.", "type": "string", "minLength": 1 }
416
+ }
345
417
  }
418
+ ]
419
+ },
420
+ "sourceTurns": {
421
+ "description": "The turns of the session the decision was made in, from and to, counted as the harness counts them. to is not before from.",
422
+ "type": "object",
423
+ "additionalProperties": false,
424
+ "required": ["from", "to"],
425
+ "properties": {
426
+ "from": { "type": "integer", "minimum": 0 },
427
+ "to": { "type": "integer", "minimum": 0 }
346
428
  }
347
429
  },
430
+ "sourceTranscript": {
431
+ "description": "The session's transcript, pinned by a path or URI and the lowercase hex SHA-256 of its bytes, never copied into the record. A reader checks the transcript it holds is the one meant; chant workspace records warns source-transcript-drift when the file can be read here and hashes to something else. A path is absolute, starts ~/ from the home directory, or starts at the workspace root; of URIs, only file: ones are read.",
432
+ "type": "object",
433
+ "additionalProperties": false,
434
+ "required": ["sha256"],
435
+ "properties": {
436
+ "path": { "type": "string", "minLength": 1 },
437
+ "uri": { "type": "string", "minLength": 1 },
438
+ "sha256": { "type": "string", "pattern": "^[0-9a-f]{64}$" }
439
+ },
440
+ "oneOf": [{ "required": ["path"] }, { "required": ["uri"] }]
441
+ },
348
442
  "issueRef": {
349
443
  "type": "string",
350
444
  "pattern": "^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+#[0-9]+$"
@@ -23,6 +23,9 @@ export const {
23
23
  readerCallProblems,
24
24
  checkReaderRead,
25
25
  selectCommands,
26
+ MCP_READ_TOOLS,
27
+ mcpToolCall,
28
+ startMcpSession,
26
29
  createConformanceWorkspace,
27
30
  conformanceTarget,
28
31
  recordingTransport,
@@ -131,6 +131,14 @@ export interface WorkspaceReaderConformanceOptions {
131
131
  chantCommand?: string[];
132
132
  /** How long one chant run may take, in milliseconds. Default 120000. */
133
133
  timeoutMs?: number;
134
+ /**
135
+ * How the reader's chant calls reach chant (#2707). `cli`, the default,
136
+ * runs the command. `mcp` answers each call with the matching tool of one
137
+ * `chant serve mcp` session started in the workspace, and also runs the
138
+ * command, so the suite holds the tool's document to the command's: they
139
+ * must be equal. `check` has no tool and is not applicable over MCP.
140
+ */
141
+ over?: "cli" | "mcp";
134
142
  }
135
143
 
136
144
  export interface WorkspaceReaderConformanceConfig extends WorkspaceReaderConformanceOptions {
@@ -289,14 +297,151 @@ export function checkReaderRead(command: ReadContractCommand, args: string[], ca
289
297
  }
290
298
 
291
299
  /** The commands to exercise and the ones skipped, from `commands`. Throws on a name that is not a contract command. */
292
- export function selectCommands(commands?: readonly ReadContractCommand[]): { checked: ReadContractCommand[]; skipped: ReadContractCommand[] } {
293
- if (commands === undefined) return { checked: [...READ_CONTRACT_COMMANDS], skipped: [] };
300
+ export function selectCommands(commands?: readonly ReadContractCommand[], over: "cli" | "mcp" = "cli"): { checked: ReadContractCommand[]; skipped: ReadContractCommand[] } {
301
+ const served = over === "mcp" ? READ_CONTRACT_COMMANDS.filter((c) => MCP_READ_TOOLS[c] !== undefined) : [...READ_CONTRACT_COMMANDS];
302
+ if (commands === undefined) return { checked: served, skipped: READ_CONTRACT_COMMANDS.filter((c) => !served.includes(c)) };
294
303
  const unknown = commands.filter((c) => !(READ_CONTRACT_COMMANDS as readonly string[]).includes(c));
295
304
  if (unknown.length > 0) throw new Error(`not read-contract commands: ${unknown.join(", ")}; the commands are ${READ_CONTRACT_COMMANDS.join(", ")}`);
296
305
  if (commands.length === 0) throw new Error("commands is empty; list at least one read-contract command");
306
+ const toolless = commands.filter((c) => !served.includes(c));
307
+ if (toolless.length > 0) throw new Error(`chant serve mcp has no tool for ${toolless.join(", ")}; over MCP the commands are ${served.join(", ")}`);
297
308
  return { checked: READ_CONTRACT_COMMANDS.filter((c) => commands.includes(c)), skipped: READ_CONTRACT_COMMANDS.filter((c) => !commands.includes(c)) };
298
309
  }
299
310
 
311
+ // ── Over MCP (#2707) ─────────────────────────────────────────────────────────
312
+
313
+ /** The `chant serve mcp` tool that answers each read-contract command. `check` has none. */
314
+ export const MCP_READ_TOOLS: Partial<Record<ReadContractCommand, string>> = {
315
+ ls: "workspace-ls",
316
+ graph: "workspace-graph",
317
+ status: "workspace-status",
318
+ records: "workspace-records",
319
+ "graph --intent": "workspace-graph",
320
+ "graph --composites": "workspace-graph",
321
+ };
322
+
323
+ /**
324
+ * The tool call that answers `chant <argv>`, a read-contract command with its
325
+ * arguments and JSON flag, or undefined when no tool does. Flags map to the
326
+ * tool's arguments of the same meaning; the JSON flag is dropped, since a tool
327
+ * always returns the document.
328
+ */
329
+ export function mcpToolCall(argv: readonly string[]): { name: string; arguments: Record<string, unknown> } | undefined {
330
+ if (argv[0] !== "workspace") return undefined;
331
+ const verb = argv[1];
332
+ const rest = argv.slice(2);
333
+ const args: Record<string, unknown> = {};
334
+ const positional: string[] = [];
335
+ const kinds: string[] = [];
336
+ for (let i = 0; i < rest.length; i++) {
337
+ const a = rest[i];
338
+ const value = (): string | undefined => rest[++i];
339
+ if (a === "--json") continue;
340
+ if (a === "--format" && rest[i + 1] === "json") {
341
+ i++;
342
+ continue;
343
+ }
344
+ if (a === "--kind") kinds.push(value() ?? "");
345
+ else if (a === "--at") args.at = value();
346
+ else if (a === "--since") args.since = value();
347
+ else if (a === "--compare-to") args.compareTo = value();
348
+ else if (a === "--intent") args.intent = value();
349
+ else if (a === "--current") args.current = true;
350
+ else if (a === "--composites") args.composites = true;
351
+ else if (a.startsWith("-")) return undefined;
352
+ else positional.push(a);
353
+ }
354
+ switch (verb) {
355
+ case "ls":
356
+ return positional.length === 0 && kinds.length === 0 ? { name: "workspace-ls", arguments: args } : undefined;
357
+ case "status":
358
+ return positional.length === 1 && kinds.length === 0 ? { name: "workspace-status", arguments: { ...args, env: positional[0] } } : undefined;
359
+ case "records":
360
+ return positional.length === 0 && kinds.length <= 1 ? { name: "workspace-records", arguments: { ...args, ...(kinds.length ? { kind: kinds[0] } : {}) } } : undefined;
361
+ case "graph":
362
+ return positional.length === 0 ? { name: "workspace-graph", arguments: { ...args, ...(kinds.length ? { kind: kinds } : {}) } } : undefined;
363
+ default:
364
+ return undefined;
365
+ }
366
+ }
367
+
368
+ /** One `chant serve mcp` session over stdio: JSON-RPC lines in, one response line per request out. */
369
+ export interface McpSession {
370
+ /** Call a tool and return its structured result, or reject with the error the server gave. */
371
+ call(name: string, args: Record<string, unknown>): Promise<unknown>;
372
+ close(): void;
373
+ }
374
+
375
+ /**
376
+ * Start `chant serve mcp` in `cwd` and initialize it as `clientInfo`. The
377
+ * server speaks over stdio only (ws-052): nothing here opens a port.
378
+ */
379
+ export async function startMcpSession(
380
+ command: string[],
381
+ cwd: string,
382
+ options: { clientInfo?: { name: string; version?: string }; timeoutMs?: number } = {},
383
+ ): Promise<McpSession> {
384
+ const child = spawn(command[0], [...command.slice(1), "serve", "mcp"], { cwd, env: { ...process.env, NO_COLOR: "1" }, stdio: ["pipe", "pipe", "pipe"] });
385
+ const pending = new Map<number, { settle: (v: unknown) => void; fail: (e: Error) => void; timer: ReturnType<typeof setTimeout> }>();
386
+ let stderr = "";
387
+ let buffered = "";
388
+ let next = 1;
389
+ const failAll = (e: Error) => {
390
+ for (const p of pending.values()) {
391
+ clearTimeout(p.timer);
392
+ p.fail(e);
393
+ }
394
+ pending.clear();
395
+ };
396
+ child.stderr.setEncoding("utf-8").on("data", (s: string) => (stderr += s));
397
+ child.stdout.setEncoding("utf-8").on("data", (s: string) => {
398
+ buffered += s;
399
+ let nl: number;
400
+ while ((nl = buffered.indexOf("\n")) >= 0) {
401
+ const line = buffered.slice(0, nl).trim();
402
+ buffered = buffered.slice(nl + 1);
403
+ if (!line) continue;
404
+ let msg: { id?: number; result?: unknown; error?: { message: string } };
405
+ try {
406
+ msg = JSON.parse(line);
407
+ } catch {
408
+ continue;
409
+ }
410
+ const p = msg.id !== undefined ? pending.get(msg.id) : undefined;
411
+ if (!p) continue;
412
+ pending.delete(msg.id!);
413
+ clearTimeout(p.timer);
414
+ if (msg.error) p.fail(new Error(msg.error.message));
415
+ else p.settle(msg.result);
416
+ }
417
+ });
418
+ child.on("error", (e) => failAll(new Error(`could not start chant serve mcp: ${e.message}`)));
419
+ child.on("close", (code) => failAll(new Error(`chant serve mcp exited (${code})${stderr.trim() ? `: ${stderr.trim()}` : ""}`)));
420
+ const request = (method: string, params: Record<string, unknown>): Promise<unknown> =>
421
+ new Promise((settle, fail) => {
422
+ const id = next++;
423
+ const timer = setTimeout(() => {
424
+ pending.delete(id);
425
+ fail(new Error(`chant serve mcp did not answer ${method} in time`));
426
+ }, options.timeoutMs ?? 120_000);
427
+ pending.set(id, { settle, fail, timer });
428
+ child.stdin.write(`${JSON.stringify({ jsonrpc: "2.0", id, method, params })}\n`);
429
+ });
430
+ await request("initialize", { protocolVersion: "2024-11-05", capabilities: {}, clientInfo: options.clientInfo ?? { name: "chant-reader-conformance" } });
431
+ child.stdin.write(`${JSON.stringify({ jsonrpc: "2.0", method: "notifications/initialized" })}\n`);
432
+ return {
433
+ async call(name, args) {
434
+ const result = (await request("tools/call", { name, arguments: args })) as { isError?: boolean; structuredContent?: unknown; content?: { text?: string }[] };
435
+ if (result.isError) throw new Error(result.content?.[0]?.text ?? `${name} failed`);
436
+ return result.structuredContent ?? JSON.parse(result.content?.[0]?.text ?? "null");
437
+ },
438
+ close() {
439
+ child.stdin.end();
440
+ child.kill();
441
+ },
442
+ };
443
+ }
444
+
300
445
  const GIT_ENV = { GIT_CONFIG_GLOBAL: process.platform === "win32" ? "NUL" : "/dev/null", GIT_CONFIG_NOSYSTEM: "1" };
301
446
 
302
447
  function git(cwd: string, ...args: string[]): void {
@@ -381,14 +526,39 @@ function runChant(command: string[], argv: string[], cwd: string, timeoutMs: num
381
526
  * A transport that runs chant in the workspace `target()` names and records
382
527
  * every call and what it printed. `reset()` clears the record before a read.
383
528
  */
384
- export function recordingTransport(target: () => { workspaceDir: string; chantCommand: string[] }, timeoutMs = 120_000) {
529
+ export function recordingTransport(target: () => { workspaceDir: string; chantCommand: string[] }, timeoutMs = 120_000, over: "cli" | "mcp" = "cli") {
385
530
  const calls: string[][] = [];
386
531
  const printed: ChantRun[] = [];
532
+ /** Over MCP: where a tool's document and the command's differ. */
533
+ const problems: string[] = [];
534
+ let session: Promise<McpSession> | undefined;
535
+ const viaMcp = async (argv: string[]): Promise<ChantRun> => {
536
+ const t = target();
537
+ const call = mcpToolCall(argv);
538
+ if (!call) return { argv: [...argv], status: null, stdout: "", stderr: `no chant serve mcp tool answers chant ${argv.join(" ")}` };
539
+ session ??= startMcpSession(t.chantCommand, t.workspaceDir, { timeoutMs });
540
+ let doc: unknown;
541
+ try {
542
+ doc = await (await session).call(call.name, call.arguments);
543
+ } catch (e) {
544
+ return { argv: [...argv], status: 1, stdout: "", stderr: `${call.name}: ${(e as Error).message}` };
545
+ }
546
+ // The command the tool stands for, run directly: the two documents must be equal.
547
+ const cli = await runChant(t.chantCommand, argv, t.workspaceDir, timeoutMs);
548
+ let printedDoc: unknown;
549
+ try {
550
+ printedDoc = JSON.parse(cli.stdout);
551
+ } catch {
552
+ printedDoc = undefined;
553
+ }
554
+ if (!isDeepStrictEqual(doc, printedDoc)) problems.push(`${argv.slice(1).join(" ")}: the MCP tool ${call.name} returned a document other than chant ${argv.join(" ")} printed`);
555
+ return { argv: [...argv], status: 0, stdout: JSON.stringify(doc), stderr: "" };
556
+ };
387
557
  const transport: ChantTransport = {
388
558
  async run(argv) {
389
559
  calls.push([...argv]);
390
560
  const t = target();
391
- const run = await runChant(t.chantCommand, argv, t.workspaceDir, timeoutMs);
561
+ const run = over === "mcp" ? await viaMcp(argv) : await runChant(t.chantCommand, argv, t.workspaceDir, timeoutMs);
392
562
  printed.push(run);
393
563
  return run;
394
564
  },
@@ -397,9 +567,16 @@ export function recordingTransport(target: () => { workspaceDir: string; chantCo
397
567
  transport,
398
568
  calls,
399
569
  printed,
570
+ problems,
400
571
  reset() {
401
572
  calls.length = 0;
402
573
  printed.length = 0;
574
+ problems.length = 0;
575
+ },
576
+ /** Stop the MCP session, when one was started. */
577
+ async close() {
578
+ if (session) (await session.catch(() => undefined))?.close();
579
+ session = undefined;
403
580
  },
404
581
  };
405
582
  }
@@ -415,7 +592,7 @@ export async function readAndCheck(reader: WorkspaceReader, recorder: ReturnType
415
592
  const stderr = recorder.printed[0]?.stderr.trim();
416
593
  return { command, args, problems: [`${command}: the reader threw: ${(e as Error).message}${stderr ? `; chant's stderr: ${stderr}` : ""}`] };
417
594
  }
418
- return { command, args, problems: checkReaderRead(command, args, recorder.calls, recorder.printed, doc) };
595
+ return { command, args, problems: [...checkReaderRead(command, args, recorder.calls, recorder.printed, doc), ...recorder.problems] };
419
596
  }
420
597
 
421
598
  /**
@@ -435,10 +612,10 @@ export async function readAndCheck(reader: WorkspaceReader, recorder: ReturnType
435
612
  * ```
436
613
  */
437
614
  export async function runWorkspaceReaderConformance(reader: WorkspaceReaderFactory, options: WorkspaceReaderConformanceOptions = {}): Promise<WorkspaceReaderConformanceReport> {
438
- const { checked, skipped } = selectCommands(options.commands);
615
+ const { checked, skipped } = selectCommands(options.commands, options.over);
439
616
  const target = conformanceTarget(options);
617
+ const recorder = recordingTransport(() => target, options.timeoutMs, options.over);
440
618
  try {
441
- const recorder = recordingTransport(() => target, options.timeoutMs);
442
619
  const built = reader(recorder.transport);
443
620
  const before = treeDigest(target.workspaceDir);
444
621
  const results: ReaderReadResult[] = [];
@@ -448,6 +625,7 @@ export async function runWorkspaceReaderConformance(reader: WorkspaceReaderFacto
448
625
  if (changed.length > 0) problems.push(`workspace: the reads changed files: ${changed.join(", ")}`);
449
626
  return { problems, checked, skipped, results, workspaceDir: target.workspaceDir };
450
627
  } finally {
628
+ await recorder.close();
451
629
  target.dispose();
452
630
  }
453
631
  }
@@ -9,6 +9,7 @@
9
9
  import { afterAll, beforeAll, describe, expect, it } from "vitest";
10
10
  import {
11
11
  conformanceTarget,
12
+ MCP_READ_TOOLS,
12
13
  READ_CONTRACT_COMMANDS,
13
14
  READ_CONTRACT_SCHEMAS,
14
15
  readAndCheck,
@@ -20,25 +21,33 @@ import {
20
21
  } from "./index";
21
22
 
22
23
  export function describeWorkspaceReaderConformance(config: WorkspaceReaderConformanceConfig): void {
23
- const { checked } = selectCommands(config.commands);
24
+ const { checked } = selectCommands(config.commands, config.over);
24
25
  let target: ReturnType<typeof conformanceTarget> | undefined;
25
- const recorder = recordingTransport(() => {
26
- if (!target) throw new Error("the conformance workspace is not ready");
27
- return target;
28
- }, config.timeoutMs);
26
+ const recorder = recordingTransport(
27
+ () => {
28
+ if (!target) throw new Error("the conformance workspace is not ready");
29
+ return target;
30
+ },
31
+ config.timeoutMs,
32
+ config.over,
33
+ );
34
+ const over = config.over === "mcp" ? " over MCP (#2707)" : "";
29
35
 
30
- describe(`workspace reader conformance (#2657): ${config.name}`, () => {
36
+ describe(`workspace reader conformance (#2657)${over}: ${config.name}`, () => {
31
37
  let before: Record<string, string> | undefined;
32
38
  const reader = config.reader(recorder.transport);
33
39
 
34
40
  beforeAll(() => {
35
41
  target = conformanceTarget(config);
36
42
  }, 300_000);
37
- afterAll(() => target?.dispose());
43
+ afterAll(async () => {
44
+ await recorder.close();
45
+ target?.dispose();
46
+ });
38
47
 
39
48
  for (const command of READ_CONTRACT_COMMANDS) {
40
49
  if (!checked.includes(command)) {
41
- it.skip(`${command}: not applicable, the reader does not list it in commands`, () => {});
50
+ it.skip(`${command}: not applicable, ${config.over === "mcp" && !MCP_READ_TOOLS[command] ? "chant serve mcp has no tool for it" : "the reader does not list it in commands"}`, () => {});
42
51
  continue;
43
52
  }
44
53
  it(
@@ -73,6 +73,7 @@ export const REASONS = {
73
73
  "record-supersedes-pending": "A supersedes link from a record whose state is weaker than the record it names, so the link has no effect yet.",
74
74
  "record-no-evidence": "The record's evidence list is empty: it cites nothing and pins no file. Information for a reviewer, never an error.",
75
75
  "review-undigested": "A verdict names no digest of the text it judged. It still counts, and an amendment does not stop it counting.",
76
+ "source-transcript-drift": "The record's source block pins a transcript by hash, the file it names can be read here, and its bytes hash to something else: it is not the transcript the record means.",
76
77
  // A work record that is valid but warned about (records and graph --intent, #2683).
77
78
  "work-needs-unknown": "A work record's needs list names a work id no record has, so the item stays blocked.",
78
79
  "work-implements-unknown": "A work record's implements list names a decision id no decision has.",
@@ -114,6 +115,8 @@ export const REASONS = {
114
115
  "review-unsupported": "The kind's schema has no reviews field, so its records take no review.",
115
116
  "review-note-required": "A dissent was given with no note: a dissent needs a reason.",
116
117
  "review-sign-failed": "--sign was given and no seal could be made: the key can't be read or used, git names no ssh signing key, or ssh-keygen is not installed.",
118
+ "record-state-not-initial": "A record written through chant serve mcp gives a state other than the kind's first: a new record opens proposed, and a person moves it on.",
119
+ "source-harvest-not-proposed": "A harvested record (source.via harvest) was written in a state other than the kind's first: a harvest proposes, and a person decides.",
117
120
  "record-sign-failed": "--sign was given and no author seal could be made: the record names no author, the key can't be read or used, git names no ssh signing key, or ssh-keygen is not installed.",
118
121
  // A review given in a session (records review --session, #2693).
119
122
  "session-unknown": "--session names no session of a session kind whose subjects are the record's kind.",
@@ -129,7 +129,8 @@
129
129
  "asset-stale",
130
130
  "record-supersedes-pending",
131
131
  "record-no-evidence",
132
- "review-undigested"
132
+ "review-undigested",
133
+ "source-transcript-drift"
133
134
  ]
134
135
  },
135
136
  "message": {