@intentius/chant 0.89.0 → 0.91.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 (194) hide show
  1. package/dist/cli/handlers/components.d.ts +8 -0
  2. package/dist/cli/handlers/components.d.ts.map +1 -1
  3. package/dist/cli/handlers/operator.d.ts +14 -0
  4. package/dist/cli/handlers/operator.d.ts.map +1 -1
  5. package/dist/cli/handlers/run.d.ts.map +1 -1
  6. package/dist/cli/handlers/serve.d.ts.map +1 -1
  7. package/dist/cli/main.d.ts.map +1 -1
  8. package/dist/cli/mcp/server.d.ts +10 -5
  9. package/dist/cli/mcp/server.d.ts.map +1 -1
  10. package/dist/cli/mcp/types.d.ts +13 -5
  11. package/dist/cli/mcp/types.d.ts.map +1 -1
  12. package/dist/cli/mcp/workspace-tools.d.ts +55 -0
  13. package/dist/cli/mcp/workspace-tools.d.ts.map +1 -0
  14. package/dist/cli/registry.d.ts +31 -0
  15. package/dist/cli/registry.d.ts.map +1 -1
  16. package/dist/components/verbs/vuln-scan.d.ts +72 -0
  17. package/dist/components/verbs/vuln-scan.d.ts.map +1 -1
  18. package/dist/lifecycle/git.d.ts +40 -5
  19. package/dist/lifecycle/git.d.ts.map +1 -1
  20. package/dist/lifecycle/lease.d.ts +72 -16
  21. package/dist/lifecycle/lease.d.ts.map +1 -1
  22. package/dist/lifecycle/member-ledger.d.ts +3 -2
  23. package/dist/lifecycle/member-ledger.d.ts.map +1 -1
  24. package/dist/lifecycle/plan-ledger.d.ts +114 -0
  25. package/dist/lifecycle/plan-ledger.d.ts.map +1 -0
  26. package/dist/lifecycle/work-lease.d.ts +140 -0
  27. package/dist/lifecycle/work-lease.d.ts.map +1 -0
  28. package/dist/op/activities/activity-contracts.d.ts +2 -2
  29. package/dist/op/builders.d.ts.map +1 -1
  30. package/dist/op/discover.d.ts +25 -0
  31. package/dist/op/discover.d.ts.map +1 -1
  32. package/dist/op/index.d.ts +9 -3
  33. package/dist/op/index.d.ts.map +1 -1
  34. package/dist/op/lifecycle-receipt-store.d.ts +34 -0
  35. package/dist/op/lifecycle-receipt-store.d.ts.map +1 -0
  36. package/dist/op/local-executor.d.ts +19 -0
  37. package/dist/op/local-executor.d.ts.map +1 -1
  38. package/dist/op/local-output.d.ts.map +1 -1
  39. package/dist/op/op-ir.d.ts +10 -1
  40. package/dist/op/op-ir.d.ts.map +1 -1
  41. package/dist/op/op-verb-class.d.ts.map +1 -1
  42. package/dist/op/operator.d.ts +29 -0
  43. package/dist/op/operator.d.ts.map +1 -1
  44. package/dist/op/runtime.d.ts +9 -0
  45. package/dist/op/runtime.d.ts.map +1 -1
  46. package/dist/op/runtimes/local.d.ts.map +1 -1
  47. package/dist/op/step-output-ref.d.ts +2 -2
  48. package/dist/op/step-output-ref.d.ts.map +1 -1
  49. package/dist/op/steward.d.ts +140 -0
  50. package/dist/op/steward.d.ts.map +1 -0
  51. package/dist/op/types.d.ts +51 -0
  52. package/dist/op/types.d.ts.map +1 -1
  53. package/dist/op/work-lease-decl.d.ts +18 -0
  54. package/dist/op/work-lease-decl.d.ts.map +1 -0
  55. package/dist/op/work-lease-run.d.ts +173 -0
  56. package/dist/op/work-lease-run.d.ts.map +1 -0
  57. package/dist/workspace/box-isolation.d.ts +99 -0
  58. package/dist/workspace/box-isolation.d.ts.map +1 -0
  59. package/dist/workspace/checks/box-isolation.d.ts +18 -0
  60. package/dist/workspace/checks/box-isolation.d.ts.map +1 -0
  61. package/dist/workspace/checks/boxes.d.ts +71 -0
  62. package/dist/workspace/checks/boxes.d.ts.map +1 -0
  63. package/dist/workspace/checks/records.d.ts +1 -0
  64. package/dist/workspace/checks/records.d.ts.map +1 -1
  65. package/dist/workspace/checks.d.ts +10 -1
  66. package/dist/workspace/checks.d.ts.map +1 -1
  67. package/dist/workspace/conformance/index.d.ts +42 -2
  68. package/dist/workspace/conformance/index.d.ts.map +1 -1
  69. package/dist/workspace/conformance/vitest.d.ts.map +1 -1
  70. package/dist/workspace/decide.d.ts +184 -0
  71. package/dist/workspace/decide.d.ts.map +1 -0
  72. package/dist/workspace/decision-points.schema.json +137 -0
  73. package/dist/workspace/declaration.d.ts +51 -0
  74. package/dist/workspace/declaration.d.ts.map +1 -1
  75. package/dist/workspace/declaration.schema.json +172 -0
  76. package/dist/workspace/declared-kinds.d.ts +12 -0
  77. package/dist/workspace/declared-kinds.d.ts.map +1 -1
  78. package/dist/workspace/points-cli.d.ts +113 -0
  79. package/dist/workspace/points-cli.d.ts.map +1 -0
  80. package/dist/workspace/points.d.ts +320 -0
  81. package/dist/workspace/points.d.ts.map +1 -0
  82. package/dist/workspace/reason-codes.d.ts +29 -0
  83. package/dist/workspace/reason-codes.d.ts.map +1 -1
  84. package/dist/workspace/record-assets.d.ts.map +1 -1
  85. package/dist/workspace/records-cli.d.ts +12 -0
  86. package/dist/workspace/records-cli.d.ts.map +1 -1
  87. package/dist/workspace/records-write.d.ts +35 -3
  88. package/dist/workspace/records-write.d.ts.map +1 -1
  89. package/dist/workspace/records.d.ts +12 -3
  90. package/dist/workspace/records.d.ts.map +1 -1
  91. package/dist/workspace/source-block.d.ts +85 -0
  92. package/dist/workspace/source-block.d.ts.map +1 -0
  93. package/dist/workspace/status-stewards.d.ts +121 -0
  94. package/dist/workspace/status-stewards.d.ts.map +1 -0
  95. package/dist/workspace/status.d.ts +52 -1
  96. package/dist/workspace/status.d.ts.map +1 -1
  97. package/dist/workspace/work-cli.d.ts +78 -0
  98. package/dist/workspace/work-cli.d.ts.map +1 -0
  99. package/package.json +1 -1
  100. package/src/cli/handlers/components.test.ts +93 -0
  101. package/src/cli/handlers/components.ts +44 -3
  102. package/src/cli/handlers/operator.ts +107 -2
  103. package/src/cli/handlers/run.test.ts +19 -0
  104. package/src/cli/handlers/run.ts +53 -1
  105. package/src/cli/handlers/serve.ts +2 -1
  106. package/src/cli/main.test.ts +40 -0
  107. package/src/cli/main.ts +78 -2
  108. package/src/cli/mcp/docs-parity.test.ts +20 -2
  109. package/src/cli/mcp/server.ts +23 -6
  110. package/src/cli/mcp/types.ts +15 -2
  111. package/src/cli/mcp/workspace-tools.test.ts +211 -0
  112. package/src/cli/mcp/workspace-tools.ts +449 -0
  113. package/src/cli/registry.ts +31 -0
  114. package/src/components/verbs/vuln-scan.test.ts +124 -1
  115. package/src/components/verbs/vuln-scan.ts +142 -1
  116. package/src/lifecycle/git.ts +65 -15
  117. package/src/lifecycle/lease.test.ts +22 -0
  118. package/src/lifecycle/lease.ts +133 -29
  119. package/src/lifecycle/member-ledger.ts +3 -2
  120. package/src/lifecycle/plan-ledger.test.ts +148 -0
  121. package/src/lifecycle/plan-ledger.ts +158 -0
  122. package/src/lifecycle/work-lease.test.ts +236 -0
  123. package/src/lifecycle/work-lease.ts +426 -0
  124. package/src/op/builders.ts +5 -0
  125. package/src/op/discover.ts +71 -0
  126. package/src/op/index.ts +16 -3
  127. package/src/op/lifecycle-receipt-store.test.ts +60 -0
  128. package/src/op/lifecycle-receipt-store.ts +61 -0
  129. package/src/op/local-executor.ts +216 -18
  130. package/src/op/local-output.ts +13 -0
  131. package/src/op/op-ir.ts +14 -0
  132. package/src/op/op-verb-class.ts +6 -0
  133. package/src/op/operator.ts +75 -4
  134. package/src/op/runtime.ts +6 -0
  135. package/src/op/runtimes/local.ts +3 -0
  136. package/src/op/step-output-ref.ts +6 -2
  137. package/src/op/steward.test.ts +212 -0
  138. package/src/op/steward.ts +253 -0
  139. package/src/op/types.ts +53 -0
  140. package/src/op/work-lease-decl.ts +80 -0
  141. package/src/op/work-lease-run.test.ts +326 -0
  142. package/src/op/work-lease-run.ts +395 -0
  143. package/src/workspace/box-isolation.test.ts +261 -0
  144. package/src/workspace/box-isolation.ts +205 -0
  145. package/src/workspace/check-contract.test.ts +3 -1
  146. package/src/workspace/check.schema.json +15 -7
  147. package/src/workspace/checks/box-isolation.ts +68 -0
  148. package/src/workspace/checks/boxes.test.ts +197 -0
  149. package/src/workspace/checks/boxes.ts +307 -0
  150. package/src/workspace/checks/records.ts +25 -0
  151. package/src/workspace/checks.test.ts +7 -0
  152. package/src/workspace/checks.ts +19 -2
  153. package/src/workspace/conformance/__fixture__/decisions/decision.kind.mjs +4 -0
  154. package/src/workspace/conformance/__fixture__/decisions/decision.schema.json +100 -6
  155. package/src/workspace/conformance/index.mjs +3 -0
  156. package/src/workspace/conformance/index.ts +185 -7
  157. package/src/workspace/conformance/vitest.ts +17 -8
  158. package/src/workspace/decide.test.ts +224 -0
  159. package/src/workspace/decide.ts +576 -0
  160. package/src/workspace/decision-points.schema.json +137 -0
  161. package/src/workspace/declaration.schema.json +172 -0
  162. package/src/workspace/declaration.ts +137 -0
  163. package/src/workspace/declared-kinds.ts +25 -2
  164. package/src/workspace/intent.schema.json +4 -1
  165. package/src/workspace/point-answer.schema.json +95 -0
  166. package/src/workspace/points-cli.ts +273 -0
  167. package/src/workspace/points-write.schema.json +489 -0
  168. package/src/workspace/points.schema.json +710 -0
  169. package/src/workspace/points.test.ts +264 -0
  170. package/src/workspace/points.ts +564 -0
  171. package/src/workspace/read-contract.test.ts +18 -1
  172. package/src/workspace/reason-codes.test.ts +14 -1
  173. package/src/workspace/reason-codes.ts +36 -0
  174. package/src/workspace/record-assets.test.ts +3 -2
  175. package/src/workspace/record-assets.ts +4 -1
  176. package/src/workspace/records-amend.schema.json +2 -1
  177. package/src/workspace/records-cli.ts +15 -2
  178. package/src/workspace/records-close.schema.json +2 -1
  179. package/src/workspace/records-contract.test.ts +3 -2
  180. package/src/workspace/records-new.schema.json +4 -1
  181. package/src/workspace/records-review.schema.json +2 -1
  182. package/src/workspace/records-write.ts +85 -7
  183. package/src/workspace/records.schema.json +18 -0
  184. package/src/workspace/records.ts +61 -3
  185. package/src/workspace/source-block.test.ts +167 -0
  186. package/src/workspace/source-block.ts +129 -0
  187. package/src/workspace/status-contract.test.ts +178 -0
  188. package/src/workspace/status-stewards.ts +225 -0
  189. package/src/workspace/status.schema.json +230 -4
  190. package/src/workspace/status.ts +106 -5
  191. package/src/workspace/work-cli.test.ts +180 -0
  192. package/src/workspace/work-cli.ts +246 -0
  193. package/src/workspace/work-lease.schema.json +233 -0
  194. package/src/workspace/work-readiness-chud.test.ts +145 -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
- 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(
@@ -0,0 +1,224 @@
1
+ /**
2
+ * Answers to decision points as records (ws-058, #2739), with a stub model
3
+ * decider, on a workspace in a throwaway git repository: `points ask` and
4
+ * `points answer` as `decide.ts` runs them, `points --json` as
5
+ * `points-cli.ts` prints it, and WSP116 in `check`.
6
+ */
7
+
8
+ import { cpSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
9
+ import { join } from "node:path";
10
+ import { afterAll, beforeAll, describe, expect, test } from "vitest";
11
+ import { runDeclarationChecks } from "./checks";
12
+ import { cleanScratch, commitAll, contract, REPO, repo } from "./__fixtures__/contract-repo";
13
+ import { answerPoint, askPoint, type PointsWriteDocument } from "./decide";
14
+ import pointAnswerSchema from "./point-answer.schema.json";
15
+ import { workspacePoints } from "./points-cli";
16
+ import pointsSchema from "./points.schema.json";
17
+ import pointsWriteSchema from "./points-write.schema.json";
18
+ import type { ModelAsk, WireAnswer } from "./points";
19
+ import { queryRecords } from "./records-cli";
20
+
21
+ const REF = join(REPO, "reference-workspace");
22
+ const POINTS = JSON.parse(readFileSync(join(REF, "decisions", "points.json"), "utf-8")) as { points: Record<string, unknown> };
23
+
24
+ const points = {
25
+ points: {
26
+ ...POINTS.points,
27
+ "needs-decision": {
28
+ title: "Does this change need a decision",
29
+ question: { type: "noul", instructions: "Does the change make a choice nobody has recorded?", criteria: { true: "It needs a decision.", false: "It does not." } },
30
+ inputs: { commit: "the commit, as graph --intent lists it" },
31
+ deciders: [
32
+ { kind: "model", backend: "systemone", model: "jev-1.13.0", threshold: 0.8 },
33
+ { kind: "quorum", count: 2, roles: ["lead"] },
34
+ ],
35
+ },
36
+ },
37
+ };
38
+
39
+ const write = contract(pointsWriteSchema);
40
+ const read = contract(pointsSchema);
41
+
42
+ let root: string;
43
+ const on = "2026-09-25";
44
+
45
+ /** A stub model decider answering every point with `answer`, at the pinned model. */
46
+ const stub =
47
+ (answer: WireAnswer): ModelAsk =>
48
+ async (req) => ({ model: req.model, answer });
49
+
50
+ const big = { "work-item.criteria": 9, "work-item.fits_small": false, "work-item.fits_medium": false };
51
+ const confident: WireAnswer = { type: "choice", choice: "large", probabilities: { small: 0.01, medium: 0.07, large: 0.92 }, confidence: 0.88 };
52
+ const unsure: WireAnswer = { type: "choice", choice: "medium", probabilities: { small: 0.2, medium: 0.45, large: 0.35 }, confidence: 0.175 };
53
+
54
+ function ok(doc: PointsWriteDocument): Extract<PointsWriteDocument, { question: unknown }> {
55
+ write.expectValid(doc);
56
+ if ("error" in doc) throw new Error(`${doc.error.code}: ${doc.error.message}`);
57
+ return doc;
58
+ }
59
+
60
+ function refused(doc: PointsWriteDocument): string {
61
+ write.expectValid(doc);
62
+ if (!("error" in doc)) throw new Error("expected a refusal");
63
+ return doc.error.code;
64
+ }
65
+
66
+ const answers = () => readdirSync(join(root, "answers")).filter((f) => f.endsWith(".md")).sort();
67
+ const fm = (path: string) => readFileSync(join(root, path), "utf-8");
68
+
69
+ beforeAll(() => {
70
+ root = repo(
71
+ {
72
+ "chant.workspace.json": JSON.stringify(
73
+ { name: "studio", schema: 1, members: [{ name: "app", dir: "app", kind: "other", because: "a plain Node server" }], records: [{ kind: "answers/answer.kind.mjs" }] },
74
+ null,
75
+ 2,
76
+ ),
77
+ "app/server.mjs": "export const port = 8080;\n",
78
+ "decisions/points.json": JSON.stringify(points, null, 2),
79
+ ".chant/trust.json": JSON.stringify({ schema: 1, roles: { agent: ["bot"], lead: ["alice", "bob", "bot"] } }),
80
+ },
81
+ false,
82
+ );
83
+ cpSync(join(REF, "answers", "answer.kind.mjs"), join(root, "answers", "answer.kind.mjs"), { recursive: true });
84
+ cpSync(join(REF, "answers", "answer.schema.json"), join(root, "answers", "answer.schema.json"));
85
+ commitAll(root, "c0");
86
+ });
87
+
88
+ afterAll(cleanScratch);
89
+
90
+ describe("points ask (#2739)", () => {
91
+ test("a table's answer is answered at once", async () => {
92
+ const doc = ok(await askPoint({ cwd: root, point: "slice-tier", inputs: { "work-item.fits_small": true }, subject: "W-001", on }));
93
+ expect(doc.question).toMatchObject({ state: "answered", open: false, answer: "small", decider: { kind: "table", row: 0 }, subject: "W-001", answeredOn: on, model: null });
94
+ expect(doc.written).toBe(true);
95
+ });
96
+
97
+ test("a model answer at or above its threshold is written as proposed, with its values and source", async () => {
98
+ const doc = ok(await askPoint({ cwd: root, point: "slice-tier", inputs: big, subject: "W-002", ask: stub(confident), on }));
99
+ expect(doc.question).toMatchObject({
100
+ state: "proposed",
101
+ open: true,
102
+ answer: "large",
103
+ decider: { kind: "model", backend: "systemone", model: "bosun-v3.1-1.7b" },
104
+ probabilities: { small: 0.01, medium: 0.07, large: 0.92 },
105
+ confidence: 0.88,
106
+ threshold: 0.8,
107
+ model: { answer: "large", confidence: 0.88, threshold: 0.8, observed: true },
108
+ escalations: [{ kind: "table", reason: "no row matches these inputs" }],
109
+ });
110
+ expect(doc.id).toBe(`slice-tier-${doc.question.inputsHash.slice(0, 12)}`);
111
+ expect(fm(doc.path)).toContain('source:\n via: "cli"\n model: "bosun-v3.1-1.7b"');
112
+ const records = await queryRecords({ kind: "answers/answer.kind.mjs", cwd: root });
113
+ if ("error" in records) throw new Error(records.error.message);
114
+ expect(records.records.find((r) => r.id === doc.id)).toMatchObject({ valid: true, state: "proposed", warnings: [] });
115
+ });
116
+
117
+ test("asking again with the same inputs returns the existing record", async () => {
118
+ const before = answers();
119
+ const first = ok(await askPoint({ cwd: root, point: "slice-tier", inputs: big, subject: "W-002", ask: stub(unsure), on }));
120
+ // Key order does not matter: the inputs hash over canonical JSON.
121
+ const again = ok(await askPoint({ cwd: root, point: "slice-tier", inputs: Object.fromEntries(Object.entries(big).reverse()), ask: stub(unsure) }));
122
+ for (const d of [first, again]) expect(d).toMatchObject({ reused: true, written: false, question: { state: "proposed", answer: "large" } });
123
+ expect(answers()).toEqual(before);
124
+ });
125
+
126
+ test("below its threshold the question is escalated and listed as open for a quorum, with the model's answer", async () => {
127
+ const doc = ok(await askPoint({ cwd: root, point: "slice-tier", inputs: { ...big, "work-item.criteria": 12 }, subject: "W-003", ask: stub(unsure), on }));
128
+ expect(doc.question).toMatchObject({ state: "escalated", open: true, answer: null, decider: { kind: "quorum", count: 1 } });
129
+ expect(fm(doc.path)).not.toMatch(/^answer:/m);
130
+ const listed = await workspacePoints({ cwd: root, open: true });
131
+ read.expectValid(listed);
132
+ if ("error" in listed) throw new Error(listed.error.message);
133
+ const q = listed.questions.find((x) => x.id === doc.id)!;
134
+ expect(q).toMatchObject({ state: "escalated", open: true, current: true, model: { answer: "medium", confidence: 0.175, threshold: 0.8, model: "bosun-v3.1-1.7b", observed: false } });
135
+ expect(q.escalations.map((e) => e.reason)).toEqual(["no row matches these inputs", "not observed: confidence 0.175 is below the threshold 0.8"]);
136
+ expect(listed.questions.every((x) => x.open)).toBe(true);
137
+ expect(listed.points.map((p) => p.name)).toEqual(["slice-tier", "ship-skip", "needs-decision"]);
138
+ expect(listed.points[0].inputs[0]).toEqual({ name: "work-item.criteria", output: "work-item", description: "acceptance criteria in the work item" });
139
+
140
+ // A later ask still escalating keeps the standing record; one a model answers rewrites it, as proposed.
141
+ const still = ok(await askPoint({ cwd: root, point: "slice-tier", inputs: { ...big, "work-item.criteria": 12 } }));
142
+ expect(still).toMatchObject({ reused: true, question: { state: "escalated" } });
143
+ const now = ok(await askPoint({ cwd: root, point: "slice-tier", inputs: { ...big, "work-item.criteria": 12 }, ask: stub(confident), on }));
144
+ expect(now).toMatchObject({ id: doc.id, path: doc.path, reused: false, question: { state: "proposed", subject: "W-003", answer: "large" } });
145
+ });
146
+
147
+ test("with --dry-run nothing is written, and the text is returned", async () => {
148
+ const before = answers();
149
+ const doc = ok(await askPoint({ cwd: root, point: "ship-skip", inputs: { "release.units": 3 }, dryRun: true, on }));
150
+ expect(doc).toMatchObject({ written: false, dryRun: true, question: { state: "answered", answer: false } });
151
+ expect((doc as { text?: string }).text).toContain("answer: false");
152
+ expect(answers()).toEqual(before);
153
+ });
154
+
155
+ test("refusals: an unknown point, undeclared inputs, a closed-failing backend", async () => {
156
+ expect(refused(await askPoint({ cwd: root, point: "nope", inputs: {} }))).toBe("point-unknown");
157
+ expect(refused(await askPoint({ cwd: root, point: "slice-tier", inputs: { "contract.size": 3 } }))).toBe("point-inputs-invalid");
158
+ expect(refused(await askPoint({ cwd: root, point: "slice-tier", inputs: [] }))).toBe("point-inputs-invalid");
159
+ });
160
+ });
161
+
162
+ describe("points answer (#2739)", () => {
163
+ test("a person confirms a model's proposal, which keeps the model as its decider", async () => {
164
+ const asked = ok(await askPoint({ cwd: root, point: "slice-tier", inputs: big, ask: stub(confident) }));
165
+ const doc = ok(await answerPoint({ cwd: root, id: asked.id, answer: "large", by: ["alice"], on }));
166
+ expect(doc.question).toMatchObject({ state: "answered", open: false, answer: "large", decider: { kind: "model", model: "bosun-v3.1-1.7b" }, confidence: 0.88, answeredBy: ["alice"], answeredOn: on });
167
+ expect(refused(await answerPoint({ cwd: root, id: asked.id, answer: "small", by: ["alice"] }))).toBe("record-closed");
168
+ // Asking again returns the confirmed answer.
169
+ expect(ok(await askPoint({ cwd: root, point: "slice-tier", inputs: big, ask: stub(unsure) }))).toMatchObject({ reused: true, question: { state: "answered" } });
170
+ });
171
+
172
+ test("people answering otherwise is the quorum's answer, and the proposal moves into the escalations", async () => {
173
+ const asked = ok(await askPoint({ cwd: root, point: "slice-tier", inputs: { ...big, "work-item.criteria": 4 }, ask: stub(confident), on }));
174
+ expect(refused(await answerPoint({ cwd: root, id: asked.id, answer: "tiny", by: ["alice"] }))).toBe("answer-not-candidate");
175
+ const doc = ok(await answerPoint({ cwd: root, id: asked.id, answer: "medium", by: ["bob"], on }));
176
+ expect(doc.question).toMatchObject({ state: "answered", answer: "medium", decider: { kind: "quorum", count: 1, by: ["bob"] }, probabilities: null, confidence: null });
177
+ expect(doc.question.escalations.at(-1)).toMatchObject({ kind: "model", answer: "large", confidence: 0.88, reason: "proposed large, and people answered medium" });
178
+ });
179
+
180
+ test("the quorum counts distinct people holding its roles, never an agent", async () => {
181
+ const asked = ok(await askPoint({ cwd: root, point: "needs-decision", inputs: { commit: { sha: "abc" } }, ask: stub({ type: "noul", noul: 0.55 }), on }));
182
+ expect(asked.question).toMatchObject({ state: "escalated", model: { answer: true, confidence: 0.55, observed: false } });
183
+ expect(refused(await answerPoint({ cwd: root, id: asked.id, answer: "yes", by: ["alice", "Alice ", "bot", "carol"] }))).toBe("quorum-not-met");
184
+ const doc = ok(await answerPoint({ cwd: root, id: asked.id, answer: "yes", by: ["alice", "bob"], on }));
185
+ expect(doc.question).toMatchObject({ state: "answered", answer: true, decider: { kind: "quorum", count: 2, roles: ["lead"], by: ["alice", "bob"] } });
186
+ });
187
+
188
+ test("an unknown id is refused", async () => {
189
+ expect(refused(await answerPoint({ cwd: root, id: "slice-tier-000000000000", answer: "small", by: ["alice"] }))).toBe("record-not-found");
190
+ });
191
+
192
+ test("every record written follows point-answer.schema.json, and the reference workspace carries a copy of it", () => {
193
+ expect(JSON.parse(readFileSync(join(REF, "answers", "answer.schema.json"), "utf-8"))).toEqual(pointAnswerSchema);
194
+ expect(answers().length).toBeGreaterThan(3);
195
+ });
196
+ });
197
+
198
+ describe("the points file in check and on read (#2738)", () => {
199
+ test("a changed point warns on its answers, and an invalid points file is WSP116 and a points-invalid source", async () => {
200
+ const file = join(root, "decisions", "points.json");
201
+ const changed = JSON.parse(readFileSync(file, "utf-8"));
202
+ changed.points["slice-tier"].title = "Which builder builds this work item";
203
+ writeFileSync(file, JSON.stringify(changed, null, 2));
204
+ const doc = await workspacePoints({ cwd: root });
205
+ read.expectValid(doc);
206
+ if ("error" in doc) throw new Error(doc.error.message);
207
+ const slice = doc.questions.filter((q) => q.point === "slice-tier");
208
+ expect(slice.every((q) => q.current === false && q.warnings.some((w) => w.code === "answer-point-changed"))).toBe(true);
209
+ expect((await runDeclarationChecks(root)).diagnostics.filter((d) => d.ruleId === "WSP116")).toEqual([]);
210
+
211
+ changed.points["slice-tier"].deciders.pop();
212
+ writeFileSync(file, JSON.stringify(changed, null, 2));
213
+ const report = await runDeclarationChecks(root);
214
+ expect(report.diagnostics.filter((d) => d.ruleId === "WSP116").map((d) => d.message)).toEqual([
215
+ "the workspace declares the answer kind answers/answer.kind.mjs, whose points file decisions/points.json has points.slice-tier.deciders, which must end in a quorum: when no table row or model answers, people do",
216
+ ]);
217
+ expect(report.ok).toBe(false);
218
+ const broken = await workspacePoints({ cwd: root });
219
+ read.expectValid(broken);
220
+ if ("error" in broken) throw new Error(broken.error.message);
221
+ expect(broken.sources[0].reason?.code).toBe("points-invalid");
222
+ expect(refused(await askPoint({ cwd: root, point: "slice-tier", inputs: big }))).toBe("points-invalid");
223
+ });
224
+ });