@intentius/chant 0.89.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/mcp/server.d.ts +10 -5
- 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-tools.d.ts +53 -0
- package/dist/cli/mcp/workspace-tools.d.ts.map +1 -0
- 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 +2 -1
- package/src/cli/mcp/docs-parity.test.ts +20 -2
- package/src/cli/mcp/server.ts +23 -6
- package/src/cli/mcp/types.ts +15 -2
- package/src/cli/mcp/workspace-tools.test.ts +198 -0
- package/src/cli/mcp/workspace-tools.ts +405 -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
|
@@ -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.",
|
|
@@ -117,7 +117,8 @@
|
|
|
117
117
|
"asset-stale",
|
|
118
118
|
"record-supersedes-pending",
|
|
119
119
|
"record-no-evidence",
|
|
120
|
-
"review-undigested"
|
|
120
|
+
"review-undigested",
|
|
121
|
+
"source-transcript-drift"
|
|
121
122
|
]
|
|
122
123
|
},
|
|
123
124
|
"message": {
|
|
@@ -161,6 +162,8 @@
|
|
|
161
162
|
"record-id-unallocatable",
|
|
162
163
|
"record-path-unmatched",
|
|
163
164
|
"record-sign-failed",
|
|
165
|
+
"source-harvest-not-proposed",
|
|
166
|
+
"record-state-not-initial",
|
|
164
167
|
"record-unparseable",
|
|
165
168
|
"record-schema-invalid",
|
|
166
169
|
"record-id-duplicate",
|
|
@@ -67,6 +67,8 @@ export const NEW_ERROR_CODES = [
|
|
|
67
67
|
"record-id-unallocatable",
|
|
68
68
|
"record-path-unmatched",
|
|
69
69
|
"record-sign-failed",
|
|
70
|
+
"source-harvest-not-proposed",
|
|
71
|
+
"record-state-not-initial",
|
|
70
72
|
...RECORD_REASON_CODES,
|
|
71
73
|
] as const satisfies readonly ReasonCode[];
|
|
72
74
|
|
|
@@ -354,7 +356,7 @@ export async function readAll(o: Opened, source: RecordSource): Promise<RecordEn
|
|
|
354
356
|
const subjectKind = await loadRecordKind(resolve(dirname(o.loaded.file), o.loaded.kind.session.subjects.kind), o.root);
|
|
355
357
|
subjects = { records: (await readRecords(subjectKind, { root: o.root, source })).records, reviews: subjectKind.kind.reviews?.field ?? "reviews" };
|
|
356
358
|
}
|
|
357
|
-
return (await readRecords(o.loaded, { root: o.root, source, assets, ...(subjects ? { subjects } : {}) })).records;
|
|
359
|
+
return (await readRecords(o.loaded, { root: o.root, source, assets, workspaceRoot: o.workspaceRoot, ...(subjects ? { subjects } : {}) })).records;
|
|
358
360
|
}
|
|
359
361
|
|
|
360
362
|
/** `base` with the file at `path` holding `text`, added to its directory when new. */
|
|
@@ -441,6 +443,24 @@ function refuseRevisionFields(kind: LoadedRecordKind["kind"], fields: Record<str
|
|
|
441
443
|
}
|
|
442
444
|
}
|
|
443
445
|
|
|
446
|
+
/**
|
|
447
|
+
* Refuse a harvested record in any state but the kind's first (#2708): a
|
|
448
|
+
* record whose source block says `via: "harvest"` came out of a transcript
|
|
449
|
+
* after the fact, so it is a proposal, and a person decides it.
|
|
450
|
+
*/
|
|
451
|
+
function refuseHarvestedDecision(kind: LoadedRecordKind["kind"], fields: Record<string, unknown>): void {
|
|
452
|
+
if (!kind.source || !kind.states || kind.stateField === undefined) return;
|
|
453
|
+
const block = fields[kind.source.field];
|
|
454
|
+
if (block === null || typeof block !== "object" || Array.isArray(block) || (block as Record<string, unknown>).via !== "harvest") return;
|
|
455
|
+
const opens = kind.states[0];
|
|
456
|
+
const state = fields[kind.stateField];
|
|
457
|
+
if (state === opens) return;
|
|
458
|
+
throw new RecordWriteError(
|
|
459
|
+
"source-harvest-not-proposed",
|
|
460
|
+
`${kind.source.field}.via is harvest, and a harvested ${kind.name} opens ${opens}, not ${JSON.stringify(state ?? null)}: write it with ${kind.stateField} "${opens}", and let the person who decides it move it on with chant workspace records amend or review`,
|
|
461
|
+
);
|
|
462
|
+
}
|
|
463
|
+
|
|
444
464
|
/**
|
|
445
465
|
* `text`, the record `data` holds, with its author sealed (#2688): an ssh
|
|
446
466
|
* signature by the key `sign` names over the record id, the digest of
|
|
@@ -594,14 +614,71 @@ export interface NewRecordOptions {
|
|
|
594
614
|
sign?: string | true;
|
|
595
615
|
}
|
|
596
616
|
|
|
617
|
+
/**
|
|
618
|
+
* A write made through a channel other than the command line (#2707), such
|
|
619
|
+
* as `chant serve mcp`. The CLI never sets one.
|
|
620
|
+
*/
|
|
621
|
+
export interface WriteChannel {
|
|
622
|
+
/**
|
|
623
|
+
* Laid over the kind's source block (#2708) when the kind declares one:
|
|
624
|
+
* `via` and `client`. A new record gets the block, made when the fields
|
|
625
|
+
* give none; an amendment only when its fields set the block.
|
|
626
|
+
*/
|
|
627
|
+
source: Record<string, unknown>;
|
|
628
|
+
/** A new record opens in the kind's first state: filled in when the fields give no state, and refused with record-state-not-initial when they give another. */
|
|
629
|
+
opensInitial?: boolean;
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
/** What a channel's write adds to the command line's: who the author is, and how it came. */
|
|
633
|
+
export interface ChannelOptions {
|
|
634
|
+
/** The record's author, written to the kind's `reviews.decider` field: the person or agent that decided. */
|
|
635
|
+
by?: string;
|
|
636
|
+
through?: WriteChannel;
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
/** `fields` with the channel's author and source laid over them (#2707). `isNew` for `records new`. */
|
|
640
|
+
function applyChannel(o: Opened, fields: Record<string, unknown>, opts: ChannelOptions, isNew: boolean, flag: string): Record<string, unknown> {
|
|
641
|
+
const { kind } = o.loaded;
|
|
642
|
+
const out = { ...fields };
|
|
643
|
+
if (opts.by !== undefined) {
|
|
644
|
+
if (!kind.reviews) throw new RecordWriteError("write-usage-invalid", `by names a record's author, the kind's reviews.decider field, and the ${kind.name} kind declares no reviews`);
|
|
645
|
+
if (opts.by.trim() === "") throw new RecordWriteError("write-usage-invalid", "by needs the name of the person or agent that decided");
|
|
646
|
+
const f = kind.reviews.decider;
|
|
647
|
+
if (out[f] !== undefined && out[f] !== null && out[f] !== opts.by) {
|
|
648
|
+
throw new RecordWriteError("write-input-invalid", `the fields given with ${flag} set ${f} to ${JSON.stringify(out[f])}, and by names ${JSON.stringify(opts.by)}: give one author`);
|
|
649
|
+
}
|
|
650
|
+
out[f] = opts.by;
|
|
651
|
+
}
|
|
652
|
+
const through = opts.through;
|
|
653
|
+
if (!through) return out;
|
|
654
|
+
if (isNew && through.opensInitial && kind.states && kind.stateField !== undefined) {
|
|
655
|
+
const first = kind.states[0];
|
|
656
|
+
const state = out[kind.stateField];
|
|
657
|
+
if (state === undefined) out[kind.stateField] = first;
|
|
658
|
+
else if (state !== first) {
|
|
659
|
+
throw new RecordWriteError(
|
|
660
|
+
"record-state-not-initial",
|
|
661
|
+
`a new ${kind.name} opens ${first}, and the fields give ${kind.stateField} ${JSON.stringify(state)}: write it ${first}, and let the person who decides it move it on with a review or an amendment`,
|
|
662
|
+
);
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
if (kind.source && (isNew || kind.source.field in out)) {
|
|
666
|
+
const f = kind.source.field;
|
|
667
|
+
const block = out[f];
|
|
668
|
+
if (block === undefined || block === null) out[f] = { ...through.source };
|
|
669
|
+
else if (typeof block === "object" && !Array.isArray(block)) out[f] = { ...(block as Record<string, unknown>), ...through.source };
|
|
670
|
+
}
|
|
671
|
+
return out;
|
|
672
|
+
}
|
|
673
|
+
|
|
597
674
|
/** `records new`: write one new record from validated fields, sealed by its author with `sign`. */
|
|
598
|
-
export async function newRecord(opts: NewRecordOptions): Promise<NewDocument> {
|
|
675
|
+
export async function newRecord(opts: NewRecordOptions & ChannelOptions): Promise<NewDocument> {
|
|
599
676
|
try {
|
|
600
677
|
if (opts.prefix !== undefined && !/^[A-Za-z][A-Za-z0-9]*$/.test(opts.prefix)) {
|
|
601
678
|
throw new RecordWriteError("write-usage-invalid", `--prefix takes letters and digits, starting with a letter, not ${JSON.stringify(opts.prefix)}`);
|
|
602
679
|
}
|
|
603
|
-
const fields = parseFields(opts.fields, "--from");
|
|
604
680
|
const o = await open(opts.kind, opts.cwd);
|
|
681
|
+
const fields = applyChannel(o, parseFields(opts.fields, "--from"), opts, true, "--from");
|
|
605
682
|
const { kind, schema } = o.loaded;
|
|
606
683
|
refuseSealField(o, fields, "--from");
|
|
607
684
|
refuseRevisionFields(kind, fields, {}, "--from");
|
|
@@ -617,6 +694,7 @@ export async function newRecord(opts: NewRecordOptions): Promise<NewDocument> {
|
|
|
617
694
|
if (taken) throw new RecordWriteError("record-id-taken", `id ${given} is already used by ${taken.path}; ids are never reused, so leave ${idField} out to have the next one allocated`);
|
|
618
695
|
id = given;
|
|
619
696
|
}
|
|
697
|
+
refuseHarvestedDecision(kind, fields);
|
|
620
698
|
// A session records the commit it opened at (#2693): HEAD now, or null before the first commit.
|
|
621
699
|
const opened = kind.session?.openedRev ? { [kind.session.openedRev]: headCommit(o.root) } : {};
|
|
622
700
|
const full = { ...fields, ...opened, [idField]: id };
|
|
@@ -676,10 +754,10 @@ export interface AmendRecordOptions {
|
|
|
676
754
|
* changes only the reviews leaves the digest, and the seal, as they were.
|
|
677
755
|
* `--sign` with nothing to change seals the record as it is.
|
|
678
756
|
*/
|
|
679
|
-
export async function amendRecord(opts: AmendRecordOptions): Promise<AmendDocument> {
|
|
757
|
+
export async function amendRecord(opts: AmendRecordOptions & ChannelOptions): Promise<AmendDocument> {
|
|
680
758
|
try {
|
|
681
|
-
const given = parseFields(opts.fields, "--set");
|
|
682
759
|
const o = await open(opts.kind, opts.cwd);
|
|
760
|
+
const given = applyChannel(o, parseFields(opts.fields, "--set"), opts, false, "--set");
|
|
683
761
|
const { kind } = o.loaded;
|
|
684
762
|
refuseSealField(o, given, "--set");
|
|
685
763
|
const before = await readAll(o, o.source);
|
|
@@ -922,7 +1000,7 @@ function usage(schema: string, message: string): WriteFailure<"write-usage-inval
|
|
|
922
1000
|
* message the verb has always given; several are refused, since a write
|
|
923
1001
|
* never guesses which kind it means.
|
|
924
1002
|
*/
|
|
925
|
-
function declaredWriteKind(schema: string, cwd: string, missing: string): string | WriteFailure<"write-usage-invalid"> {
|
|
1003
|
+
export function declaredWriteKind(schema: string, cwd: string, missing: string): string | WriteFailure<"write-usage-invalid"> {
|
|
926
1004
|
let kinds: ReturnType<typeof declaredKindFiles>;
|
|
927
1005
|
try {
|
|
928
1006
|
kinds = declaredKindFiles(cwd);
|
|
@@ -978,7 +1056,7 @@ function readInput(schema: string, flag: string, value: string | undefined, cwd:
|
|
|
978
1056
|
* The session kind `records close` goes through when none is named (#2693):
|
|
979
1057
|
* the one session kind the declaration names.
|
|
980
1058
|
*/
|
|
981
|
-
async function declaredSessionKind(schema: string, cwd: string): Promise<string | WriteFailure<"write-usage-invalid">> {
|
|
1059
|
+
export async function declaredSessionKind(schema: string, cwd: string): Promise<string | WriteFailure<"write-usage-invalid">> {
|
|
982
1060
|
const kinds = (await findSessionKinds(cwd)).map((k) => k.file);
|
|
983
1061
|
if (kinds.length === 1) return kinds[0];
|
|
984
1062
|
if (kinds.length === 0) return usage(schema, "--kind <session kind file> is required: the declaration names no session kind");
|
package/src/workspace/records.ts
CHANGED
|
@@ -29,6 +29,7 @@ import type { ReasonCode } from "./reason-codes";
|
|
|
29
29
|
import { checkPins, pinEntries, type AssetPin } from "./record-assets";
|
|
30
30
|
import { joinSessions, type SessionCitation } from "./record-sessions";
|
|
31
31
|
import type { RecordSource } from "./record-source";
|
|
32
|
+
import { sourceBlock, sourceBlockProblems, transcriptDrift } from "./source-block";
|
|
32
33
|
import type { WorkspaceTree } from "./tree";
|
|
33
34
|
import type { DecisionWork, WorkLink, WorkWarningCode } from "./work";
|
|
34
35
|
|
|
@@ -84,6 +85,12 @@ export const RECORD_WARNING_CODES = [
|
|
|
84
85
|
* amendment does not stop it counting (#2672).
|
|
85
86
|
*/
|
|
86
87
|
"review-undigested",
|
|
88
|
+
/**
|
|
89
|
+
* The kind's source block pins a transcript by hash, the file it names can
|
|
90
|
+
* be read here, and its bytes hash to something else: it is not the
|
|
91
|
+
* transcript the record means (#2708).
|
|
92
|
+
*/
|
|
93
|
+
"source-transcript-drift",
|
|
87
94
|
] as const satisfies readonly ReasonCode[];
|
|
88
95
|
export type RecordWarningCode = (typeof RECORD_WARNING_CODES)[number];
|
|
89
96
|
|
|
@@ -299,6 +306,15 @@ export const recordKindSchema = z
|
|
|
299
306
|
* `withdrawn_on`. Optional.
|
|
300
307
|
*/
|
|
301
308
|
reviews: z.object({ field: z.string().min(1), decider: z.string().min(1) }).strict().optional(),
|
|
309
|
+
/**
|
|
310
|
+
* The front-matter object that says where a record came from, opted in
|
|
311
|
+
* to the stored source block (#2708): `via`, `client`, `harness`,
|
|
312
|
+
* `model`, `session`, `turns` and `transcript`, beside the kind's own
|
|
313
|
+
* fields in the same object. With it, those fields are checked for their
|
|
314
|
+
* shapes, a write with `via: "harvest"` must open in the kind's first
|
|
315
|
+
* state, and `records` warns `source-transcript-drift`. Optional.
|
|
316
|
+
*/
|
|
317
|
+
source: z.object({ field: z.string().min(1) }).strict().optional(),
|
|
302
318
|
/**
|
|
303
319
|
* A review-session kind (#2673, #2650 C10): the front-matter list of the
|
|
304
320
|
* verdicts a session produced, the field that seals a closed session, and
|
|
@@ -1076,6 +1092,8 @@ export async function readRecords(loaded: LoadedRecordKind, options: ReadRecords
|
|
|
1076
1092
|
const match = new RegExp(kind.location.match);
|
|
1077
1093
|
const validate = await compileSchema(loaded.schema, loaded.refs);
|
|
1078
1094
|
const workspaceRoot = options.workspaceRoot ?? ".";
|
|
1095
|
+
// Where a relative transcript path resolves (#2708): the workspace root in the working tree.
|
|
1096
|
+
const workspaceDir = workspaceRoot === "." ? options.root : resolve(options.root, ...workspaceRoot.split("/"));
|
|
1079
1097
|
|
|
1080
1098
|
const entries: RecordEntry[] = [];
|
|
1081
1099
|
const texts = new Map<string, string>();
|
|
@@ -1127,6 +1145,16 @@ export async function readRecords(loaded: LoadedRecordKind, options: ReadRecords
|
|
|
1127
1145
|
if (!result.ok) {
|
|
1128
1146
|
entry.reasons.push({ code: "record-schema-invalid", message: result.errors.join("; ") });
|
|
1129
1147
|
}
|
|
1148
|
+
if (kind.source) {
|
|
1149
|
+
// The stored source block (#2708): its proposal fields' shapes, and the transcript it pins.
|
|
1150
|
+
const block = sourceBlock(fm.value, kind.source.field);
|
|
1151
|
+
if (block) {
|
|
1152
|
+
const problems = sourceBlockProblems(block, kind.source.field);
|
|
1153
|
+
if (problems.length > 0) entry.reasons.push({ code: "record-schema-invalid", message: problems.join("; ") });
|
|
1154
|
+
const drift = transcriptDrift(block, kind.source.field, workspaceDir);
|
|
1155
|
+
if (drift) entry.warnings.push({ code: "source-transcript-drift", message: drift });
|
|
1156
|
+
}
|
|
1157
|
+
}
|
|
1130
1158
|
if (kind.pins) {
|
|
1131
1159
|
const cited = fm.value[kind.pins.field];
|
|
1132
1160
|
if (Array.isArray(cited) && cited.length === 0) {
|