@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.
- 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 +20 -6
- package/dist/cli/mcp/server.d.ts.map +1 -1
- package/dist/cli/mcp/types.d.ts +13 -5
- package/dist/cli/mcp/types.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/mcp/workspace-tools.d.ts +53 -0
- package/dist/cli/mcp/workspace-tools.d.ts.map +1 -0
- package/dist/cli/registry.d.ts +2 -0
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/op/op-verb-class.d.ts.map +1 -1
- package/dist/workspace/conformance/index.d.ts +42 -2
- package/dist/workspace/conformance/index.d.ts.map +1 -1
- package/dist/workspace/conformance/vitest.d.ts.map +1 -1
- package/dist/workspace/reason-codes.d.ts +3 -0
- package/dist/workspace/reason-codes.d.ts.map +1 -1
- package/dist/workspace/records-write.d.ts +35 -3
- package/dist/workspace/records-write.d.ts.map +1 -1
- package/dist/workspace/records.d.ts +4 -1
- package/dist/workspace/records.d.ts.map +1 -1
- package/dist/workspace/source-block.d.ts +85 -0
- package/dist/workspace/source-block.d.ts.map +1 -0
- package/package.json +1 -1
- package/src/cli/handlers/serve.ts +11 -1
- package/src/cli/main.test.ts +7 -0
- package/src/cli/main.ts +40 -1
- package/src/cli/mcp/docs-parity.test.ts +20 -2
- package/src/cli/mcp/server.ts +32 -6
- package/src/cli/mcp/types.ts +15 -2
- package/src/cli/mcp/workspace-plugins.ts +123 -0
- package/src/cli/mcp/workspace-tools.test.ts +198 -0
- package/src/cli/mcp/workspace-tools.ts +405 -0
- package/src/cli/registry.ts +2 -0
- package/src/cli/serve-mcp-workspace.test.ts +142 -0
- package/src/op/op-verb-class.ts +6 -0
- package/src/workspace/conformance/__fixture__/decisions/decision.kind.mjs +4 -0
- package/src/workspace/conformance/__fixture__/decisions/decision.schema.json +100 -6
- package/src/workspace/conformance/index.mjs +3 -0
- package/src/workspace/conformance/index.ts +185 -7
- package/src/workspace/conformance/vitest.ts +17 -8
- package/src/workspace/reason-codes.ts +3 -0
- package/src/workspace/records-amend.schema.json +2 -1
- package/src/workspace/records-close.schema.json +2 -1
- package/src/workspace/records-new.schema.json +4 -1
- package/src/workspace/records-review.schema.json +2 -1
- package/src/workspace/records-write.ts +85 -7
- package/src/workspace/records.schema.json +1 -0
- package/src/workspace/records.ts +28 -0
- package/src/workspace/source-block.test.ts +167 -0
- 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
|
+
});
|
package/src/op/op-verb-class.ts
CHANGED
|
@@ -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,
|
|
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
|
-
"
|
|
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]+$"
|
|
@@ -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
|
-
|
|
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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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(() =>
|
|
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.",
|