@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
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #2707 — the workspace tools of `chant serve mcp`: the read contract and the
|
|
3
|
+
* record writes, for a harness that speaks MCP and has no shell.
|
|
4
|
+
*
|
|
5
|
+
* Served when the server starts at or inside a declared workspace. Each tool
|
|
6
|
+
* is a thin call into the code the CLI runs:
|
|
7
|
+
*
|
|
8
|
+
* - The reads (`workspace-ls`, `workspace-status`, `workspace-graph`,
|
|
9
|
+
* `workspace-records`) run `chant workspace <command> ... --json` with the
|
|
10
|
+
* chant this server runs as, in the server's directory, and return the
|
|
11
|
+
* document it printed, unchanged, with its reason codes (#2536). Running the
|
|
12
|
+
* command, rather than calling into it, keeps every rule it has, handing the
|
|
13
|
+
* read to the workspace root's pinned chant included (ws-021), and keeps
|
|
14
|
+
* anything a command prints away from the protocol on stdout.
|
|
15
|
+
* - The writes (`records-new`, `records-amend`, `records-review`,
|
|
16
|
+
* `records-close`) call the functions `chant workspace records new|amend|
|
|
17
|
+
* review|close` call, and return the same JSON result. They keep every rule
|
|
18
|
+
* the CLI keeps: the kind's schema, a closed record never changing, an
|
|
19
|
+
* approved one changing only as its approval rule allows, a dissent needing
|
|
20
|
+
* a note, and `sign` using this host's configured key or refusing with the
|
|
21
|
+
* CLI's remedy. Through MCP a new record also opens in the kind's first
|
|
22
|
+
* state, `proposed` for a decision, and its stored source block (#2708) says
|
|
23
|
+
* it came through MCP: `via: "mcp"` and `client`, the MCP client's
|
|
24
|
+
* `clientInfo`. Computed provenance, from git, is unchanged.
|
|
25
|
+
*
|
|
26
|
+
* Nothing here listens on a port or authenticates anyone (ws-052): the server
|
|
27
|
+
* speaks over stdio, and `by` is recorded as given, as `--by` is.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { spawn } from "node:child_process";
|
|
31
|
+
import type { ToolContext, ToolDefinition, ToolHandler } from "./types";
|
|
32
|
+
|
|
33
|
+
/** How the reads run chant: a command and its leading arguments. */
|
|
34
|
+
export type ChantCommand = string[];
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The chant this process runs as: node with the same flags (the tsx loader
|
|
38
|
+
* `bin/chant` registers) and the same entry script.
|
|
39
|
+
*/
|
|
40
|
+
export function ownChantCommand(): ChantCommand {
|
|
41
|
+
return [process.execPath, ...process.execArgv, process.argv[1]];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface WorkspaceToolsOptions {
|
|
45
|
+
/** Where reads run and writes resolve kinds: the directory the server started in. */
|
|
46
|
+
cwd: string;
|
|
47
|
+
/** The chant the reads run. Defaults to {@link ownChantCommand}. */
|
|
48
|
+
chantCommand?: ChantCommand;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const PROTOCOL =
|
|
52
|
+
"Records are proposals until they are reviewed: a new record opens proposed, and people decide it through reviews and amendments. " +
|
|
53
|
+
"by must name the person or agent that actually decided, as it is recorded as given.";
|
|
54
|
+
|
|
55
|
+
const kindProp = {
|
|
56
|
+
type: "string",
|
|
57
|
+
description:
|
|
58
|
+
"The record kind: a kind file relative to the server's directory, such as decisions/decision.kind.mjs, or a kind the workspace declaration names. Without it, the one kind the declaration names.",
|
|
59
|
+
};
|
|
60
|
+
const atProp = { type: "string", description: "Read at this git revision instead of the working tree (--at)." };
|
|
61
|
+
const dryRunProp = { type: "boolean", description: "Return the result, with the text it would write, and write nothing (--dry-run)." };
|
|
62
|
+
const signProp = {
|
|
63
|
+
type: "boolean",
|
|
64
|
+
description:
|
|
65
|
+
"Seal with this host's configured key, git's user.signingkey with gpg.format ssh, as --sign does. Refused, with the CLI's remedy, when none is set.",
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
export const workspaceReadTools: ToolDefinition[] = [
|
|
69
|
+
{
|
|
70
|
+
name: "workspace-ls",
|
|
71
|
+
description:
|
|
72
|
+
"List the workspace's members and groups: chant workspace ls --json. Returns that document unchanged, which follows ls.schema.json of the read contract, reason codes included.",
|
|
73
|
+
inputSchema: { type: "object", properties: { at: atProp } },
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
name: "workspace-status",
|
|
77
|
+
description:
|
|
78
|
+
"What each member has released to an environment, from the lifecycle ledgers: chant workspace status <env> --json. Returns that document unchanged (status.schema.json).",
|
|
79
|
+
inputSchema: {
|
|
80
|
+
type: "object",
|
|
81
|
+
properties: {
|
|
82
|
+
env: { type: "string", description: "The environment, such as dev." },
|
|
83
|
+
compareTo: { type: "string", description: "A second environment to compare with (--compare-to)." },
|
|
84
|
+
},
|
|
85
|
+
required: ["env"],
|
|
86
|
+
},
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
name: "workspace-graph",
|
|
90
|
+
description:
|
|
91
|
+
"The workspace graph: chant workspace graph --json (graph.schema.json); with intent, the intent graph over one region (graph --intent, intent.schema.json); with composites, each composite instance and the components that can deploy it (graph --composites, composites.schema.json). Returns the document unchanged.",
|
|
92
|
+
inputSchema: {
|
|
93
|
+
type: "object",
|
|
94
|
+
properties: {
|
|
95
|
+
kind: {
|
|
96
|
+
type: "array",
|
|
97
|
+
items: { type: "string" },
|
|
98
|
+
description: "Record kind files whose records join the graph (--kind). The plain graph takes one; intent takes several.",
|
|
99
|
+
},
|
|
100
|
+
intent: { type: "string", description: "The region for the intent graph: a workspace path, path:line or path:start-end (--intent)." },
|
|
101
|
+
composites: { type: "boolean", description: "The composites document instead (--composites). Takes no kind or intent." },
|
|
102
|
+
at: atProp,
|
|
103
|
+
},
|
|
104
|
+
},
|
|
105
|
+
},
|
|
106
|
+
{
|
|
107
|
+
name: "workspace-records",
|
|
108
|
+
description:
|
|
109
|
+
"The records of a kind, validated, with supersession, provenance, quorum and warnings: chant workspace records --json (records.schema.json). With since, what changed since a revision or a review session (records-since.schema.json). Returns the document unchanged; with id, only that record is kept in records. " +
|
|
110
|
+
PROTOCOL,
|
|
111
|
+
inputSchema: {
|
|
112
|
+
type: "object",
|
|
113
|
+
properties: {
|
|
114
|
+
kind: kindProp,
|
|
115
|
+
current: { type: "boolean", description: "Leave out records a closed record supersedes (--current)." },
|
|
116
|
+
since: { type: "string", description: "A revision, or a review session id, to compare with (--since)." },
|
|
117
|
+
id: { type: "string", description: "Keep only the record with this id in the document's records." },
|
|
118
|
+
at: atProp,
|
|
119
|
+
},
|
|
120
|
+
},
|
|
121
|
+
},
|
|
122
|
+
];
|
|
123
|
+
|
|
124
|
+
export const workspaceWriteTools: ToolDefinition[] = [
|
|
125
|
+
{
|
|
126
|
+
name: "records-new",
|
|
127
|
+
description:
|
|
128
|
+
"Propose a new record: chant workspace records new. The record opens in the kind's first state (proposed for a decision); another state is refused. Its source block records that it came through MCP (via mcp, and this client's clientInfo). Validated against the kind's schema; nothing is written on refusal, and nothing is committed. " +
|
|
129
|
+
PROTOCOL,
|
|
130
|
+
inputSchema: {
|
|
131
|
+
type: "object",
|
|
132
|
+
properties: {
|
|
133
|
+
kind: kindProp,
|
|
134
|
+
record: { type: "object", description: "The record's fields, as the kind's schema describes them. The id is allocated when left out." },
|
|
135
|
+
prefix: { type: "string", description: "The id prefix to allocate under, when the records use several (--prefix)." },
|
|
136
|
+
by: { type: "string", description: "Who decided: written to the kind's decider field, such as decided_by. Required to sign." },
|
|
137
|
+
sign: signProp,
|
|
138
|
+
dryRun: dryRunProp,
|
|
139
|
+
},
|
|
140
|
+
required: ["record"],
|
|
141
|
+
},
|
|
142
|
+
},
|
|
143
|
+
{
|
|
144
|
+
name: "records-amend",
|
|
145
|
+
description:
|
|
146
|
+
"Set top-level fields of a record: chant workspace records amend. A closed record never changes, and an approved one changes only in its state, evidence and reviews: anything else, its reasoning included, is a new record that supersedes it. A source block given in the fields records that the change came through MCP. " +
|
|
147
|
+
PROTOCOL,
|
|
148
|
+
inputSchema: {
|
|
149
|
+
type: "object",
|
|
150
|
+
properties: {
|
|
151
|
+
id: { type: "string", description: "The record's id." },
|
|
152
|
+
kind: kindProp,
|
|
153
|
+
fields: { type: "object", description: "The top-level fields to set; each replaces the whole field." },
|
|
154
|
+
by: { type: "string", description: "Who decided: written to the kind's decider field." },
|
|
155
|
+
sign: signProp,
|
|
156
|
+
dryRun: dryRunProp,
|
|
157
|
+
},
|
|
158
|
+
required: ["id", "fields"],
|
|
159
|
+
},
|
|
160
|
+
},
|
|
161
|
+
{
|
|
162
|
+
name: "records-review",
|
|
163
|
+
description:
|
|
164
|
+
"Give a verdict on a record: chant workspace records review. Appends one entry to its reviews with the digest of the text judged, which moves its quorum. A dissent needs a note. " +
|
|
165
|
+
PROTOCOL,
|
|
166
|
+
inputSchema: {
|
|
167
|
+
type: "object",
|
|
168
|
+
properties: {
|
|
169
|
+
id: { type: "string", description: "The record's id." },
|
|
170
|
+
kind: kindProp,
|
|
171
|
+
verdict: { type: "string", enum: ["agree", "dissent", "abstain"] },
|
|
172
|
+
by: { type: "string", description: "The reviewer: the person or agent giving the verdict (--by)." },
|
|
173
|
+
note: { type: "string", description: "Why; required for a dissent (--note)." },
|
|
174
|
+
session: { type: "string", description: "The open review session the verdict is given in (--session)." },
|
|
175
|
+
sign: signProp,
|
|
176
|
+
dryRun: dryRunProp,
|
|
177
|
+
},
|
|
178
|
+
required: ["id", "verdict", "by"],
|
|
179
|
+
},
|
|
180
|
+
},
|
|
181
|
+
{
|
|
182
|
+
name: "records-close",
|
|
183
|
+
description: "Close an open review session and seal it: chant workspace records close. " + PROTOCOL,
|
|
184
|
+
inputSchema: {
|
|
185
|
+
type: "object",
|
|
186
|
+
properties: {
|
|
187
|
+
id: { type: "string", description: "The session's id." },
|
|
188
|
+
kind: { type: "string", description: "The session kind file. Without it, the one session kind the declaration names." },
|
|
189
|
+
dryRun: dryRunProp,
|
|
190
|
+
},
|
|
191
|
+
required: ["id"],
|
|
192
|
+
},
|
|
193
|
+
},
|
|
194
|
+
];
|
|
195
|
+
|
|
196
|
+
/** A tool call that can't be made as given: the client gets it as an error result, and nothing runs. */
|
|
197
|
+
class ToolInputError extends Error {}
|
|
198
|
+
|
|
199
|
+
function str(params: Record<string, unknown>, key: string, required = false): string | undefined {
|
|
200
|
+
const v = params[key];
|
|
201
|
+
if (v === undefined || v === null) {
|
|
202
|
+
if (required) throw new ToolInputError(`${key} is required`);
|
|
203
|
+
return undefined;
|
|
204
|
+
}
|
|
205
|
+
if (typeof v !== "string" || v === "") throw new ToolInputError(`${key} must be a non-empty string`);
|
|
206
|
+
// A value is passed as one argument; one that starts with - would read as a flag.
|
|
207
|
+
if (v.startsWith("-")) throw new ToolInputError(`${key} may not start with -: ${JSON.stringify(v)}`);
|
|
208
|
+
return v;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function bool(params: Record<string, unknown>, key: string): boolean {
|
|
212
|
+
const v = params[key];
|
|
213
|
+
if (v === undefined || v === null) return false;
|
|
214
|
+
if (typeof v !== "boolean") throw new ToolInputError(`${key} must be true or false`);
|
|
215
|
+
return v;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function obj(params: Record<string, unknown>, key: string): Record<string, unknown> {
|
|
219
|
+
const v = params[key];
|
|
220
|
+
if (v === null || typeof v !== "object" || Array.isArray(v)) throw new ToolInputError(`${key} must be a JSON object`);
|
|
221
|
+
return v as Record<string, unknown>;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function kinds(params: Record<string, unknown>): string[] {
|
|
225
|
+
const v = params.kind;
|
|
226
|
+
if (v === undefined || v === null) return [];
|
|
227
|
+
const list = typeof v === "string" ? [v] : v;
|
|
228
|
+
if (!Array.isArray(list)) throw new ToolInputError("kind must be a kind file, or a list of them");
|
|
229
|
+
return list.map((k, i) => str({ [`kind[${i}]`]: k }, `kind[${i}]`, true)!);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** The `chant` arguments each read tool runs, from its input. Exported for the conformance suite's MCP transport. */
|
|
233
|
+
export function readArgv(tool: string, params: Record<string, unknown>): string[] {
|
|
234
|
+
const at = str(params, "at");
|
|
235
|
+
const atArgs = at !== undefined ? ["--at", at] : [];
|
|
236
|
+
switch (tool) {
|
|
237
|
+
case "workspace-ls":
|
|
238
|
+
return ["workspace", "ls", ...atArgs, "--json"];
|
|
239
|
+
case "workspace-status": {
|
|
240
|
+
const env = str(params, "env", true)!;
|
|
241
|
+
const compareTo = str(params, "compareTo");
|
|
242
|
+
return ["workspace", "status", env, ...(compareTo !== undefined ? ["--compare-to", compareTo] : []), "--json"];
|
|
243
|
+
}
|
|
244
|
+
case "workspace-graph": {
|
|
245
|
+
const intent = str(params, "intent");
|
|
246
|
+
const composites = bool(params, "composites");
|
|
247
|
+
const kindArgs = kinds(params).flatMap((k) => ["--kind", k]);
|
|
248
|
+
if (composites) return ["workspace", "graph", "--composites", ...kindArgs, ...(intent !== undefined ? ["--intent", intent] : []), ...atArgs, "--json"];
|
|
249
|
+
if (intent !== undefined) return ["workspace", "graph", "--intent", intent, ...kindArgs, ...atArgs, "--json"];
|
|
250
|
+
return ["workspace", "graph", ...kindArgs, ...atArgs, "--json"];
|
|
251
|
+
}
|
|
252
|
+
case "workspace-records": {
|
|
253
|
+
const kind = str(params, "kind");
|
|
254
|
+
const since = str(params, "since");
|
|
255
|
+
return [
|
|
256
|
+
"workspace",
|
|
257
|
+
"records",
|
|
258
|
+
...(kind !== undefined ? ["--kind", kind] : []),
|
|
259
|
+
...(bool(params, "current") ? ["--current"] : []),
|
|
260
|
+
...(since !== undefined ? ["--since", since] : []),
|
|
261
|
+
...atArgs,
|
|
262
|
+
"--json",
|
|
263
|
+
];
|
|
264
|
+
}
|
|
265
|
+
default:
|
|
266
|
+
throw new ToolInputError(`${tool} is not a workspace read tool`);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** Run chant and parse the one JSON document it printed. The exit code does not matter: an error document is a document. */
|
|
271
|
+
function runRead(command: ChantCommand, argv: string[], cwd: string): Promise<unknown> {
|
|
272
|
+
return new Promise((settle, fail) => {
|
|
273
|
+
const child = spawn(command[0], [...command.slice(1), ...argv], { cwd, env: { ...process.env, NO_COLOR: "1" }, stdio: ["ignore", "pipe", "pipe"] });
|
|
274
|
+
let stdout = "";
|
|
275
|
+
let stderr = "";
|
|
276
|
+
child.stdout.setEncoding("utf-8").on("data", (s: string) => (stdout += s));
|
|
277
|
+
child.stderr.setEncoding("utf-8").on("data", (s: string) => (stderr += s));
|
|
278
|
+
child.on("error", (e) => fail(new Error(`could not run chant ${argv.join(" ")}: ${e.message}`)));
|
|
279
|
+
child.on("close", (status) => {
|
|
280
|
+
try {
|
|
281
|
+
settle(JSON.parse(stdout));
|
|
282
|
+
} catch {
|
|
283
|
+
const why = stderr.trim() || stdout.trim() || `exit ${status}`;
|
|
284
|
+
fail(new Error(`chant ${argv.join(" ")} printed no JSON document: ${why}`));
|
|
285
|
+
}
|
|
286
|
+
});
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** Keep only the record with `id` in a records document, or in each kind of a declared set. */
|
|
291
|
+
function onlyRecord(doc: unknown, id: string): unknown {
|
|
292
|
+
if (doc === null || typeof doc !== "object") return doc;
|
|
293
|
+
const d = doc as Record<string, unknown>;
|
|
294
|
+
if (Array.isArray(d.records)) return { ...d, records: (d.records as { id?: unknown }[]).filter((r) => r.id === id) };
|
|
295
|
+
if (Array.isArray(d.kinds)) return { ...d, kinds: d.kinds.map((k) => onlyRecord(k, id)) };
|
|
296
|
+
return doc;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** The source block a write through MCP lays over the record's (#2708). */
|
|
300
|
+
function mcpSource(context: ToolContext | undefined): Record<string, unknown> {
|
|
301
|
+
const c = context?.clientInfo;
|
|
302
|
+
const client =
|
|
303
|
+
c && typeof c.name === "string" && c.name !== ""
|
|
304
|
+
? {
|
|
305
|
+
name: c.name,
|
|
306
|
+
...(typeof c.version === "string" && c.version !== "" ? { version: c.version } : {}),
|
|
307
|
+
...(typeof c.title === "string" && c.title !== "" ? { title: c.title } : {}),
|
|
308
|
+
}
|
|
309
|
+
: undefined;
|
|
310
|
+
return { via: "mcp", ...(client ? { client } : {}) };
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
export interface WorkspaceTool {
|
|
314
|
+
definition: ToolDefinition;
|
|
315
|
+
handler: ToolHandler;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/** The workspace tools, reads then writes, bound to the server's directory. */
|
|
319
|
+
export function createWorkspaceTools(options: WorkspaceToolsOptions): WorkspaceTool[] {
|
|
320
|
+
const { cwd } = options;
|
|
321
|
+
const chant = options.chantCommand ?? ownChantCommand();
|
|
322
|
+
const reads: WorkspaceTool[] = workspaceReadTools.map((definition) => ({
|
|
323
|
+
definition,
|
|
324
|
+
handler: async (params) => {
|
|
325
|
+
const argv = readArgv(definition.name, params);
|
|
326
|
+
const id = definition.name === "workspace-records" ? str(params, "id") : undefined;
|
|
327
|
+
const doc = await runRead(chant, argv, cwd);
|
|
328
|
+
return id !== undefined ? onlyRecord(doc, id) : doc;
|
|
329
|
+
},
|
|
330
|
+
}));
|
|
331
|
+
|
|
332
|
+
const write = async () => import("../../workspace/records-write");
|
|
333
|
+
/** The kind file a write goes through: the one named, or the one the declaration names; else the CLI's usage failure. */
|
|
334
|
+
const writeKind = async (params: Record<string, unknown>, schema: string, missing: string): Promise<string | object> => {
|
|
335
|
+
const w = await write();
|
|
336
|
+
const named = str(params, "kind");
|
|
337
|
+
return named !== undefined ? w.resolveWriteKind(named, cwd) : w.declaredWriteKind(schema, cwd, missing);
|
|
338
|
+
};
|
|
339
|
+
const sign = (params: Record<string, unknown>): true | undefined => (bool(params, "sign") ? true : undefined);
|
|
340
|
+
|
|
341
|
+
const handlers: Record<string, ToolHandler> = {
|
|
342
|
+
"records-new": async (params, context) => {
|
|
343
|
+
const w = await write();
|
|
344
|
+
const record = obj(params, "record");
|
|
345
|
+
const kind = await writeKind(params, w.RECORDS_NEW_SCHEMA_ID, "new needs the kind file");
|
|
346
|
+
if (typeof kind !== "string") return kind;
|
|
347
|
+
return w.newRecord({
|
|
348
|
+
kind,
|
|
349
|
+
fields: JSON.stringify(record),
|
|
350
|
+
prefix: str(params, "prefix"),
|
|
351
|
+
by: str(params, "by"),
|
|
352
|
+
sign: sign(params),
|
|
353
|
+
dryRun: bool(params, "dryRun"),
|
|
354
|
+
cwd,
|
|
355
|
+
through: { source: mcpSource(context), opensInitial: true },
|
|
356
|
+
});
|
|
357
|
+
},
|
|
358
|
+
"records-amend": async (params, context) => {
|
|
359
|
+
const w = await write();
|
|
360
|
+
const id = str(params, "id", true)!;
|
|
361
|
+
const fields = obj(params, "fields");
|
|
362
|
+
const kind = await writeKind(params, w.RECORDS_AMEND_SCHEMA_ID, "--kind <kind file> is required");
|
|
363
|
+
if (typeof kind !== "string") return kind;
|
|
364
|
+
return w.amendRecord({
|
|
365
|
+
kind,
|
|
366
|
+
id,
|
|
367
|
+
fields: JSON.stringify(fields),
|
|
368
|
+
by: str(params, "by"),
|
|
369
|
+
sign: sign(params),
|
|
370
|
+
dryRun: bool(params, "dryRun"),
|
|
371
|
+
cwd,
|
|
372
|
+
through: { source: mcpSource(context) },
|
|
373
|
+
});
|
|
374
|
+
},
|
|
375
|
+
"records-review": async (params) => {
|
|
376
|
+
const w = await write();
|
|
377
|
+
const id = str(params, "id", true)!;
|
|
378
|
+
const kind = await writeKind(params, w.RECORDS_REVIEW_SCHEMA_ID, "--kind <kind file> is required");
|
|
379
|
+
if (typeof kind !== "string") return kind;
|
|
380
|
+
return w.reviewRecord({
|
|
381
|
+
kind,
|
|
382
|
+
id,
|
|
383
|
+
verdict: str(params, "verdict", true)!,
|
|
384
|
+
by: str(params, "by", true)!,
|
|
385
|
+
note: typeof params.note === "string" ? params.note : undefined,
|
|
386
|
+
session: str(params, "session"),
|
|
387
|
+
sign: sign(params),
|
|
388
|
+
dryRun: bool(params, "dryRun"),
|
|
389
|
+
cwd,
|
|
390
|
+
});
|
|
391
|
+
},
|
|
392
|
+
"records-close": async (params) => {
|
|
393
|
+
const w = await write();
|
|
394
|
+
const { closeRecord, RECORDS_CLOSE_SCHEMA_ID } = await import("../../workspace/records-close");
|
|
395
|
+
const id = str(params, "id", true)!;
|
|
396
|
+
const named = str(params, "kind");
|
|
397
|
+
const kind = named !== undefined ? w.resolveWriteKind(named, cwd) : await w.declaredSessionKind(RECORDS_CLOSE_SCHEMA_ID, cwd);
|
|
398
|
+
if (typeof kind !== "string") return kind;
|
|
399
|
+
return closeRecord({ kind, id, dryRun: bool(params, "dryRun"), cwd });
|
|
400
|
+
},
|
|
401
|
+
};
|
|
402
|
+
|
|
403
|
+
const writes: WorkspaceTool[] = workspaceWriteTools.map((definition) => ({ definition, handler: handlers[definition.name] }));
|
|
404
|
+
return [...reads, ...writes];
|
|
405
|
+
}
|
package/src/op/op-verb-class.ts
CHANGED
|
@@ -47,6 +47,10 @@ const READ_ONLY_ACTIVITY_FNS: ReadonlySet<string> = new Set([
|
|
|
47
47
|
"spriteListDir",
|
|
48
48
|
"listCheckpoints",
|
|
49
49
|
"convergeTick",
|
|
50
|
+
"spriteUrl",
|
|
51
|
+
"spriteServiceGet",
|
|
52
|
+
"spriteServiceList",
|
|
53
|
+
"spriteServiceLogs",
|
|
50
54
|
]);
|
|
51
55
|
|
|
52
56
|
/** Activity function names that always delete/destroy, regardless of args. */
|
|
@@ -57,6 +61,8 @@ const ALWAYS_DESTRUCTIVE_ACTIVITY_FNS: ReadonlySet<string> = new Set([
|
|
|
57
61
|
"awsDelete",
|
|
58
62
|
"gcpDelete",
|
|
59
63
|
"spriteDestroy",
|
|
64
|
+
"spriteDelete",
|
|
65
|
+
"spriteServiceDelete",
|
|
60
66
|
"k3dDown",
|
|
61
67
|
"k3sUninstall",
|
|
62
68
|
]);
|
|
@@ -29,4 +29,8 @@ export const recordKind = {
|
|
|
29
29
|
// Verdicts, and the field naming the decider, for each record's digest and
|
|
30
30
|
// quorum (#2671, #2672).
|
|
31
31
|
reviews: { field: "reviews", decider: "decided_by" },
|
|
32
|
+
// source is where a decision came from, and says where its proposal came
|
|
33
|
+
// from too: via, client, harness, model, session, turns and a transcript
|
|
34
|
+
// pinned by hash (#2708).
|
|
35
|
+
source: { field: "source" },
|
|
32
36
|
};
|
|
@@ -50,8 +50,8 @@
|
|
|
50
50
|
"type": ["string", "null"]
|
|
51
51
|
},
|
|
52
52
|
"source": {
|
|
53
|
-
"description": "Where the decision was first recorded: a row of an issue's decisions table,
|
|
54
|
-
"oneOf": [{ "$ref": "#/definitions/sourceIssue" }, { "$ref": "#/definitions/sourceWorkspace" }]
|
|
53
|
+
"description": "Where the decision was first recorded: a row of an issue's decisions table, the workspace itself when no issue holds the decision (#2654), or a harness session when neither does (#2708). Each form may also say where the proposal came from: the fields via, client, harness, model, session, turns and transcript, which the kind opts in to with source: { field: \"source\" }.",
|
|
54
|
+
"oneOf": [{ "$ref": "#/definitions/sourceIssue" }, { "$ref": "#/definitions/sourceWorkspace" }, { "$ref": "#/definitions/sourceHarness" }]
|
|
55
55
|
},
|
|
56
56
|
"question": {
|
|
57
57
|
"description": "One sentence. Null only in an importer draft.",
|
|
@@ -319,7 +319,14 @@
|
|
|
319
319
|
"revision": {
|
|
320
320
|
"description": "The design revision that last changed this row, such as \"v8\". Null when the row carries no marker.",
|
|
321
321
|
"type": ["string", "null"]
|
|
322
|
-
}
|
|
322
|
+
},
|
|
323
|
+
"session": { "$ref": "#/definitions/sourceSession" },
|
|
324
|
+
"via": { "$ref": "#/definitions/sourceVia" },
|
|
325
|
+
"client": { "$ref": "#/definitions/sourceClient" },
|
|
326
|
+
"harness": { "$ref": "#/definitions/sourceHarnessId" },
|
|
327
|
+
"model": { "$ref": "#/definitions/sourceModel" },
|
|
328
|
+
"turns": { "$ref": "#/definitions/sourceTurns" },
|
|
329
|
+
"transcript": { "$ref": "#/definitions/sourceTranscript" }
|
|
323
330
|
}
|
|
324
331
|
},
|
|
325
332
|
"sourceWorkspace": {
|
|
@@ -335,16 +342,103 @@
|
|
|
335
342
|
"pattern": "^[a-z0-9][a-z0-9-]{0,39}$"
|
|
336
343
|
},
|
|
337
344
|
"session": {
|
|
338
|
-
"description": "The id of the session the decision was made in, as the member names it. Null or absent when there was none.",
|
|
339
|
-
"
|
|
340
|
-
"minLength": 1
|
|
345
|
+
"description": "The id of the session the decision was made in, as the member names it, or the harness's session with the chant session record it was held in. Null or absent when there was none.",
|
|
346
|
+
"$ref": "#/definitions/sourceSession"
|
|
341
347
|
},
|
|
342
348
|
"issue": {
|
|
343
349
|
"description": "An issue the decision relates to, when there is one.",
|
|
344
350
|
"$ref": "#/definitions/issueRef"
|
|
351
|
+
},
|
|
352
|
+
"via": { "$ref": "#/definitions/sourceVia" },
|
|
353
|
+
"client": { "$ref": "#/definitions/sourceClient" },
|
|
354
|
+
"harness": { "$ref": "#/definitions/sourceHarnessId" },
|
|
355
|
+
"model": { "$ref": "#/definitions/sourceModel" },
|
|
356
|
+
"turns": { "$ref": "#/definitions/sourceTurns" },
|
|
357
|
+
"transcript": { "$ref": "#/definitions/sourceTranscript" }
|
|
358
|
+
}
|
|
359
|
+
},
|
|
360
|
+
"sourceHarness": {
|
|
361
|
+
"description": "A decision proposed in a harness session, with no issue row or workspace member behind it (#2708). It says how it arrived, in via, and may say the rest of where the proposal came from.",
|
|
362
|
+
"type": "object",
|
|
363
|
+
"additionalProperties": false,
|
|
364
|
+
"required": ["via"],
|
|
365
|
+
"properties": {
|
|
366
|
+
"issue": {
|
|
367
|
+
"description": "An issue the decision relates to, when there is one.",
|
|
368
|
+
"$ref": "#/definitions/issueRef"
|
|
369
|
+
},
|
|
370
|
+
"session": { "$ref": "#/definitions/sourceSession" },
|
|
371
|
+
"via": { "$ref": "#/definitions/sourceVia" },
|
|
372
|
+
"client": { "$ref": "#/definitions/sourceClient" },
|
|
373
|
+
"harness": { "$ref": "#/definitions/sourceHarnessId" },
|
|
374
|
+
"model": { "$ref": "#/definitions/sourceModel" },
|
|
375
|
+
"turns": { "$ref": "#/definitions/sourceTurns" },
|
|
376
|
+
"transcript": { "$ref": "#/definitions/sourceTranscript" }
|
|
377
|
+
}
|
|
378
|
+
},
|
|
379
|
+
"sourceVia": {
|
|
380
|
+
"description": "How the record reached the workspace: cli, a person or script at chant workspace records new; mcp, a harness through chant serve mcp, which fills this and client itself; harvest, a transcript read after the fact. A harvested decision is written proposed, and a person decides it. Data about the proposal, not trust.",
|
|
381
|
+
"enum": ["cli", "mcp", "harvest"]
|
|
382
|
+
},
|
|
383
|
+
"sourceClient": {
|
|
384
|
+
"description": "What wrote the record: the MCP client's clientInfo, or the CLI.",
|
|
385
|
+
"type": "object",
|
|
386
|
+
"additionalProperties": false,
|
|
387
|
+
"required": ["name"],
|
|
388
|
+
"properties": {
|
|
389
|
+
"name": { "type": "string", "minLength": 1 },
|
|
390
|
+
"version": { "type": "string", "minLength": 1 },
|
|
391
|
+
"title": { "type": "string", "minLength": 1 }
|
|
392
|
+
}
|
|
393
|
+
},
|
|
394
|
+
"sourceHarnessId": {
|
|
395
|
+
"description": "The harness the decision was proposed in, such as claude-code, codex, gemini-cli, opencode, fountain or hud.",
|
|
396
|
+
"type": "string",
|
|
397
|
+
"minLength": 1
|
|
398
|
+
},
|
|
399
|
+
"sourceModel": {
|
|
400
|
+
"description": "The model id the harness reports.",
|
|
401
|
+
"type": "string",
|
|
402
|
+
"minLength": 1
|
|
403
|
+
},
|
|
404
|
+
"sourceSession": {
|
|
405
|
+
"description": "The session the decision was made in: its id as a string, or an object with the harness's session or conversation id and the chant session record it was held in (#2697). Null when there was none.",
|
|
406
|
+
"oneOf": [
|
|
407
|
+
{ "type": "string", "minLength": 1 },
|
|
408
|
+
{ "type": "null" },
|
|
409
|
+
{
|
|
410
|
+
"type": "object",
|
|
411
|
+
"additionalProperties": false,
|
|
412
|
+
"minProperties": 1,
|
|
413
|
+
"properties": {
|
|
414
|
+
"id": { "description": "The harness's session or conversation id.", "type": "string", "minLength": 1 },
|
|
415
|
+
"record": { "description": "The id of the chant session record the decision was held in.", "type": "string", "minLength": 1 }
|
|
416
|
+
}
|
|
345
417
|
}
|
|
418
|
+
]
|
|
419
|
+
},
|
|
420
|
+
"sourceTurns": {
|
|
421
|
+
"description": "The turns of the session the decision was made in, from and to, counted as the harness counts them. to is not before from.",
|
|
422
|
+
"type": "object",
|
|
423
|
+
"additionalProperties": false,
|
|
424
|
+
"required": ["from", "to"],
|
|
425
|
+
"properties": {
|
|
426
|
+
"from": { "type": "integer", "minimum": 0 },
|
|
427
|
+
"to": { "type": "integer", "minimum": 0 }
|
|
346
428
|
}
|
|
347
429
|
},
|
|
430
|
+
"sourceTranscript": {
|
|
431
|
+
"description": "The session's transcript, pinned by a path or URI and the lowercase hex SHA-256 of its bytes, never copied into the record. A reader checks the transcript it holds is the one meant; chant workspace records warns source-transcript-drift when the file can be read here and hashes to something else. A path is absolute, starts ~/ from the home directory, or starts at the workspace root; of URIs, only file: ones are read.",
|
|
432
|
+
"type": "object",
|
|
433
|
+
"additionalProperties": false,
|
|
434
|
+
"required": ["sha256"],
|
|
435
|
+
"properties": {
|
|
436
|
+
"path": { "type": "string", "minLength": 1 },
|
|
437
|
+
"uri": { "type": "string", "minLength": 1 },
|
|
438
|
+
"sha256": { "type": "string", "pattern": "^[0-9a-f]{64}$" }
|
|
439
|
+
},
|
|
440
|
+
"oneOf": [{ "required": ["path"] }, { "required": ["uri"] }]
|
|
441
|
+
},
|
|
348
442
|
"issueRef": {
|
|
349
443
|
"type": "string",
|
|
350
444
|
"pattern": "^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+#[0-9]+$"
|