@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.
- package/dist/cli/handlers/components.d.ts +8 -0
- package/dist/cli/handlers/components.d.ts.map +1 -1
- package/dist/cli/handlers/operator.d.ts +14 -0
- package/dist/cli/handlers/operator.d.ts.map +1 -1
- package/dist/cli/handlers/run.d.ts.map +1 -1
- package/dist/cli/handlers/serve.d.ts.map +1 -1
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/mcp/server.d.ts +10 -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 +55 -0
- package/dist/cli/mcp/workspace-tools.d.ts.map +1 -0
- package/dist/cli/registry.d.ts +31 -0
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/components/verbs/vuln-scan.d.ts +72 -0
- package/dist/components/verbs/vuln-scan.d.ts.map +1 -1
- package/dist/lifecycle/git.d.ts +40 -5
- package/dist/lifecycle/git.d.ts.map +1 -1
- package/dist/lifecycle/lease.d.ts +72 -16
- package/dist/lifecycle/lease.d.ts.map +1 -1
- package/dist/lifecycle/member-ledger.d.ts +3 -2
- package/dist/lifecycle/member-ledger.d.ts.map +1 -1
- package/dist/lifecycle/plan-ledger.d.ts +114 -0
- package/dist/lifecycle/plan-ledger.d.ts.map +1 -0
- package/dist/lifecycle/work-lease.d.ts +140 -0
- package/dist/lifecycle/work-lease.d.ts.map +1 -0
- package/dist/op/activities/activity-contracts.d.ts +2 -2
- package/dist/op/builders.d.ts.map +1 -1
- package/dist/op/discover.d.ts +25 -0
- package/dist/op/discover.d.ts.map +1 -1
- package/dist/op/index.d.ts +9 -3
- package/dist/op/index.d.ts.map +1 -1
- package/dist/op/lifecycle-receipt-store.d.ts +34 -0
- package/dist/op/lifecycle-receipt-store.d.ts.map +1 -0
- package/dist/op/local-executor.d.ts +19 -0
- package/dist/op/local-executor.d.ts.map +1 -1
- package/dist/op/local-output.d.ts.map +1 -1
- package/dist/op/op-ir.d.ts +10 -1
- package/dist/op/op-ir.d.ts.map +1 -1
- package/dist/op/op-verb-class.d.ts.map +1 -1
- package/dist/op/operator.d.ts +29 -0
- package/dist/op/operator.d.ts.map +1 -1
- package/dist/op/runtime.d.ts +9 -0
- package/dist/op/runtime.d.ts.map +1 -1
- package/dist/op/runtimes/local.d.ts.map +1 -1
- package/dist/op/step-output-ref.d.ts +2 -2
- package/dist/op/step-output-ref.d.ts.map +1 -1
- package/dist/op/steward.d.ts +140 -0
- package/dist/op/steward.d.ts.map +1 -0
- package/dist/op/types.d.ts +51 -0
- package/dist/op/types.d.ts.map +1 -1
- package/dist/op/work-lease-decl.d.ts +18 -0
- package/dist/op/work-lease-decl.d.ts.map +1 -0
- package/dist/op/work-lease-run.d.ts +173 -0
- package/dist/op/work-lease-run.d.ts.map +1 -0
- package/dist/workspace/box-isolation.d.ts +99 -0
- package/dist/workspace/box-isolation.d.ts.map +1 -0
- package/dist/workspace/checks/box-isolation.d.ts +18 -0
- package/dist/workspace/checks/box-isolation.d.ts.map +1 -0
- package/dist/workspace/checks/boxes.d.ts +71 -0
- package/dist/workspace/checks/boxes.d.ts.map +1 -0
- package/dist/workspace/checks/records.d.ts +1 -0
- package/dist/workspace/checks/records.d.ts.map +1 -1
- package/dist/workspace/checks.d.ts +10 -1
- package/dist/workspace/checks.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/decide.d.ts +184 -0
- package/dist/workspace/decide.d.ts.map +1 -0
- package/dist/workspace/decision-points.schema.json +137 -0
- package/dist/workspace/declaration.d.ts +51 -0
- package/dist/workspace/declaration.d.ts.map +1 -1
- package/dist/workspace/declaration.schema.json +172 -0
- package/dist/workspace/declared-kinds.d.ts +12 -0
- package/dist/workspace/declared-kinds.d.ts.map +1 -1
- package/dist/workspace/points-cli.d.ts +113 -0
- package/dist/workspace/points-cli.d.ts.map +1 -0
- package/dist/workspace/points.d.ts +320 -0
- package/dist/workspace/points.d.ts.map +1 -0
- package/dist/workspace/reason-codes.d.ts +29 -0
- package/dist/workspace/reason-codes.d.ts.map +1 -1
- package/dist/workspace/record-assets.d.ts.map +1 -1
- package/dist/workspace/records-cli.d.ts +12 -0
- package/dist/workspace/records-cli.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 +12 -3
- 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/dist/workspace/status-stewards.d.ts +121 -0
- package/dist/workspace/status-stewards.d.ts.map +1 -0
- package/dist/workspace/status.d.ts +52 -1
- package/dist/workspace/status.d.ts.map +1 -1
- package/dist/workspace/work-cli.d.ts +78 -0
- package/dist/workspace/work-cli.d.ts.map +1 -0
- package/package.json +1 -1
- package/src/cli/handlers/components.test.ts +93 -0
- package/src/cli/handlers/components.ts +44 -3
- package/src/cli/handlers/operator.ts +107 -2
- package/src/cli/handlers/run.test.ts +19 -0
- package/src/cli/handlers/run.ts +53 -1
- package/src/cli/handlers/serve.ts +2 -1
- package/src/cli/main.test.ts +40 -0
- package/src/cli/main.ts +78 -2
- 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 +211 -0
- package/src/cli/mcp/workspace-tools.ts +449 -0
- package/src/cli/registry.ts +31 -0
- package/src/components/verbs/vuln-scan.test.ts +124 -1
- package/src/components/verbs/vuln-scan.ts +142 -1
- package/src/lifecycle/git.ts +65 -15
- package/src/lifecycle/lease.test.ts +22 -0
- package/src/lifecycle/lease.ts +133 -29
- package/src/lifecycle/member-ledger.ts +3 -2
- package/src/lifecycle/plan-ledger.test.ts +148 -0
- package/src/lifecycle/plan-ledger.ts +158 -0
- package/src/lifecycle/work-lease.test.ts +236 -0
- package/src/lifecycle/work-lease.ts +426 -0
- package/src/op/builders.ts +5 -0
- package/src/op/discover.ts +71 -0
- package/src/op/index.ts +16 -3
- package/src/op/lifecycle-receipt-store.test.ts +60 -0
- package/src/op/lifecycle-receipt-store.ts +61 -0
- package/src/op/local-executor.ts +216 -18
- package/src/op/local-output.ts +13 -0
- package/src/op/op-ir.ts +14 -0
- package/src/op/op-verb-class.ts +6 -0
- package/src/op/operator.ts +75 -4
- package/src/op/runtime.ts +6 -0
- package/src/op/runtimes/local.ts +3 -0
- package/src/op/step-output-ref.ts +6 -2
- package/src/op/steward.test.ts +212 -0
- package/src/op/steward.ts +253 -0
- package/src/op/types.ts +53 -0
- package/src/op/work-lease-decl.ts +80 -0
- package/src/op/work-lease-run.test.ts +326 -0
- package/src/op/work-lease-run.ts +395 -0
- package/src/workspace/box-isolation.test.ts +261 -0
- package/src/workspace/box-isolation.ts +205 -0
- package/src/workspace/check-contract.test.ts +3 -1
- package/src/workspace/check.schema.json +15 -7
- package/src/workspace/checks/box-isolation.ts +68 -0
- package/src/workspace/checks/boxes.test.ts +197 -0
- package/src/workspace/checks/boxes.ts +307 -0
- package/src/workspace/checks/records.ts +25 -0
- package/src/workspace/checks.test.ts +7 -0
- package/src/workspace/checks.ts +19 -2
- 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/decide.test.ts +224 -0
- package/src/workspace/decide.ts +576 -0
- package/src/workspace/decision-points.schema.json +137 -0
- package/src/workspace/declaration.schema.json +172 -0
- package/src/workspace/declaration.ts +137 -0
- package/src/workspace/declared-kinds.ts +25 -2
- package/src/workspace/intent.schema.json +4 -1
- package/src/workspace/point-answer.schema.json +95 -0
- package/src/workspace/points-cli.ts +273 -0
- package/src/workspace/points-write.schema.json +489 -0
- package/src/workspace/points.schema.json +710 -0
- package/src/workspace/points.test.ts +264 -0
- package/src/workspace/points.ts +564 -0
- package/src/workspace/read-contract.test.ts +18 -1
- package/src/workspace/reason-codes.test.ts +14 -1
- package/src/workspace/reason-codes.ts +36 -0
- package/src/workspace/record-assets.test.ts +3 -2
- package/src/workspace/record-assets.ts +4 -1
- package/src/workspace/records-amend.schema.json +2 -1
- package/src/workspace/records-cli.ts +15 -2
- package/src/workspace/records-close.schema.json +2 -1
- package/src/workspace/records-contract.test.ts +3 -2
- 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 +18 -0
- package/src/workspace/records.ts +61 -3
- package/src/workspace/source-block.test.ts +167 -0
- package/src/workspace/source-block.ts +129 -0
- package/src/workspace/status-contract.test.ts +178 -0
- package/src/workspace/status-stewards.ts +225 -0
- package/src/workspace/status.schema.json +230 -4
- package/src/workspace/status.ts +106 -5
- package/src/workspace/work-cli.test.ts +180 -0
- package/src/workspace/work-cli.ts +246 -0
- package/src/workspace/work-lease.schema.json +233 -0
- 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
|
-
|
|
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(
|
|
@@ -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
|
+
});
|