yarramate 1.25.1 → 1.26.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 (34) hide show
  1. package/README.md +6 -2
  2. package/dist/adapters/mcp-cli.d.ts +45 -1
  3. package/dist/adapters/mcp-cli.js +297 -50
  4. package/dist/adapters/visual/protocol-contract.d.ts +29 -2
  5. package/dist/adapters/visual/session-server.js +27 -6
  6. package/dist/adapters/visual/session-store.js +1 -0
  7. package/dist/adapters/visual/view-identity.d.ts +13 -0
  8. package/dist/adapters/visual/view-identity.js +29 -11
  9. package/dist/adapters/visual/wire.d.ts +18 -0
  10. package/dist/adapters/visual/workspace-model.js +11 -1
  11. package/dist/visual-app/assets/{elk.bundled-CLux5E_P.js → elk.bundled-CYZFv-C6.js} +1 -1
  12. package/dist/visual-app/assets/{index-DGkU2CSB.js → index-CRYHv5jy.js} +6 -2
  13. package/dist/visual-app/assets/index-DdE5s692.css +1 -0
  14. package/dist/visual-app/index.html +2 -2
  15. package/dist/visual-app-lib/editor.js +7827 -7497
  16. package/dist/visual-app-lib/styles.css +1 -1
  17. package/dist/visual-app-lib/types/adapters/visual/protocol-contract.d.ts +29 -2
  18. package/dist/visual-app-lib/types/adapters/visual/view-identity.d.ts +13 -0
  19. package/dist/visual-app-lib/types/adapters/visual/wire.d.ts +18 -0
  20. package/dist/visual-app-lib/types/visual-app/App.d.ts +7 -1
  21. package/dist/visual-app-lib/types/visual-app/context-menu-model.d.ts +36 -0
  22. package/dist/visual-app-lib/types/visual-app/mount.d.ts +9 -1
  23. package/dist/visual-app-lib/types/visual-app/open-questions.d.ts +18 -2
  24. package/dist/visual-app-lib/types/visual-app/question-verbs.d.ts +54 -0
  25. package/dist/visual-app-lib/types/visual-app/session-client.d.ts +3 -0
  26. package/dist/visual-app-lib/types/visual-app/state.d.ts +4 -1
  27. package/dist/visual-app-lib/types/visual-app/workspace-state.d.ts +13 -0
  28. package/docs/CONSUMING-YARRAMATE.md +17 -5
  29. package/package.json +1 -1
  30. package/schema/yarramate-visual-event.schema.json +324 -73
  31. package/schema/yarramate-visual-response.schema.json +230 -56
  32. package/skills/yarramate-architecture/SKILL.md +6 -2
  33. package/skills/yarramate-architecture/references/visual-conversations.md +21 -3
  34. package/dist/visual-app/assets/index-CJVWkpLt.css +0 -1
package/README.md CHANGED
@@ -163,8 +163,12 @@ diff — merging is the human acceptance step, not yours.
163
163
  verdicts, and attestations, with a `path:line` citation per cell.
164
164
  - `init` writes the discovery pointer into both `AGENTS.md` and
165
165
  `CLAUDE.md`, so this section finds you rather than the reverse.
166
- - `yarramate-mcp` exposes four read-only tools (ask/design/check/reconcile)
167
- over MCP stdio.
166
+ - `yarramate-mcp` exposes the whole loop over MCP stdio: `yarramate_ask`,
167
+ `yarramate_design`, `yarramate_apply`, `yarramate_check`,
168
+ `yarramate_reconcile` and `yarramate_export`. Every tool call runs the same
169
+ CLI; `apply` is the one write and lands the same atomic batch. Start it
170
+ inside the repository, or anywhere with `--workspace <path>`, and the
171
+ `workspace` argument becomes optional (#514).
168
172
  - In Claude Code, this repository is its own plugin marketplace:
169
173
 
170
174
  ```sh
@@ -5,5 +5,49 @@ interface JsonRpcRequest {
5
5
  readonly method: string;
6
6
  readonly params?: Record<string, unknown>;
7
7
  }
8
- export declare const handleRequest: (request: JsonRpcRequest) => void;
8
+ /**
9
+ * Where a tool call resolves its workspace when the call names none
10
+ * (#514). In order: the `--workspace` the server was started with, then the
11
+ * conventional `.yarramate/workspace.yaml` under the working directory. A
12
+ * desktop app starts the server from a directory that is not the repository,
13
+ * so the first is the one that matters there; a terminal agent runs it from
14
+ * the repository, so the second is what it gets for free.
15
+ */
16
+ export interface ServerOptions {
17
+ readonly workspace?: string;
18
+ }
19
+ /** What a tool call needs from the server beyond its own arguments. */
20
+ export interface ToolContext {
21
+ readonly cwd: string;
22
+ readonly workspace: string | undefined;
23
+ }
24
+ /**
25
+ * The loop, in two sentences, on every tool. A desktop-app agent connecting
26
+ * for the first time has never seen the skill file; the tool list is the only
27
+ * place it learns that design asks, apply lands, and design asks again.
28
+ */
29
+ export declare const LOOP = "The loop: call yarramate_design for the top open question, answer it with the person, land the answer with yarramate_apply, then call yarramate_design again. Every tool takes the same optional `workspace`; omit it to use the workspace this server was started with, or .yarramate/workspace.yaml under its working directory.";
30
+ export declare const resolveWorkspace: (input: Record<string, unknown>, context: ToolContext) => string | undefined;
31
+ /**
32
+ * The CLI runs inside the repository: a manifest's globs resolve against the
33
+ * manifest, but a record's contracts, coverage and evidence name files by
34
+ * their repository-root path, which is the directory that holds `.yarramate`.
35
+ * A desktop app starts this server anywhere, so a workspace given as a path
36
+ * from elsewhere is run as the CLI would be run by a person standing in that
37
+ * repository: working directory at the root, manifest path relative to it.
38
+ */
39
+ export declare const workingDirectoryFor: (workspace: string, cwd: string) => {
40
+ readonly cwd: string;
41
+ readonly workspace: string;
42
+ };
43
+ /** The tool list a client sees; exported so a test can read it without stdio. */
44
+ export declare const toolCatalogue: {
45
+ name: string;
46
+ description: string;
47
+ inputSchema: Record<string, unknown>;
48
+ }[];
49
+ export declare const handleRequest: (request: JsonRpcRequest, context?: ToolContext) => void;
50
+ /** `yarramate-mcp [--workspace <path>]`; anything else is refused with usage. */
51
+ export declare const parseServerOptions: (argv: readonly string[]) => ServerOptions | undefined;
52
+ export declare const serverUsage = "Usage:\n yarramate-mcp [--workspace <workspace.yaml>]\n yarramate-mcp --version\n";
9
53
  export {};
@@ -1,20 +1,95 @@
1
1
  #!/usr/bin/env node
2
+ import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync, } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { basename, dirname, join, relative, resolve } from 'node:path';
2
5
  import { createInterface } from 'node:readline';
3
6
  import { isMainModule, packageVersion, versionResult, } from '../cli-support.js';
4
7
  import { runCli } from '../cli.js';
8
+ /**
9
+ * The loop, in two sentences, on every tool. A desktop-app agent connecting
10
+ * for the first time has never seen the skill file; the tool list is the only
11
+ * place it learns that design asks, apply lands, and design asks again.
12
+ */
13
+ export const LOOP = 'The loop: call yarramate_design for the top open question, answer it with the person, land the answer with yarramate_apply, then call yarramate_design again. Every tool takes the same optional `workspace`; omit it to use the workspace this server was started with, or .yarramate/workspace.yaml under its working directory.';
5
14
  const workspaceProperty = {
6
15
  workspace: {
7
16
  type: 'string',
8
- description: 'Path to the explicit workspace manifest, for example .yarramate/workspace.yaml',
17
+ description: "Path to the workspace manifest, for example .yarramate/workspace.yaml. Optional: defaults to the server's --workspace, then to .yarramate/workspace.yaml under the working directory. Desktop apps start the server outside the repository, so give the full path there.",
9
18
  },
10
19
  };
20
+ const CONVENTIONAL_WORKSPACE = join('.yarramate', 'workspace.yaml');
21
+ /** The one refusal that is the server's own rather than the CLI's. */
22
+ const noWorkspace = () => ({
23
+ exitCode: 2,
24
+ stdout: '',
25
+ stderr: 'No workspace to work on. Pass `workspace` (the path to workspace.yaml) in the call, or start yarramate-mcp with --workspace <path>; a server started inside a repository that holds .yarramate/workspace.yaml needs neither.\n',
26
+ });
27
+ export const resolveWorkspace = (input, context) => {
28
+ if (typeof input.workspace === 'string' && input.workspace.length > 0) {
29
+ return input.workspace;
30
+ }
31
+ if (context.workspace !== undefined)
32
+ return context.workspace;
33
+ return existsSync(resolve(context.cwd, CONVENTIONAL_WORKSPACE))
34
+ ? CONVENTIONAL_WORKSPACE
35
+ : undefined;
36
+ };
37
+ /**
38
+ * The CLI runs inside the repository: a manifest's globs resolve against the
39
+ * manifest, but a record's contracts, coverage and evidence name files by
40
+ * their repository-root path, which is the directory that holds `.yarramate`.
41
+ * A desktop app starts this server anywhere, so a workspace given as a path
42
+ * from elsewhere is run as the CLI would be run by a person standing in that
43
+ * repository: working directory at the root, manifest path relative to it.
44
+ */
45
+ export const workingDirectoryFor = (workspace, cwd) => {
46
+ const manifest = resolve(cwd, workspace);
47
+ const holder = dirname(manifest);
48
+ const root = basename(holder) === '.yarramate' ? dirname(holder) : holder;
49
+ return { cwd: root, workspace: relative(root, manifest) };
50
+ };
51
+ const withWorkspace = (input, context, run) => {
52
+ const named = resolveWorkspace(input, context);
53
+ if (named === undefined)
54
+ return noWorkspace();
55
+ const { cwd, workspace } = workingDirectoryFor(named, context.cwd);
56
+ return run(workspace, cwd);
57
+ };
58
+ /**
59
+ * A scratch directory for one call, removed before the answer goes back.
60
+ * `apply` reads its operations from a path and `export` writes some kinds only
61
+ * to a directory; an MCP call carries neither, so the adapter lends both a
62
+ * place on disk for the duration of the call and nothing longer.
63
+ */
64
+ const withScratch = (use) => {
65
+ const directory = mkdtempSync(join(tmpdir(), 'yarramate-mcp-'));
66
+ try {
67
+ return use(directory);
68
+ }
69
+ finally {
70
+ rmSync(directory, { recursive: true, force: true });
71
+ }
72
+ };
73
+ const operationsText = (operations) => {
74
+ if (typeof operations === 'string')
75
+ return operations;
76
+ if (typeof operations === 'object' && operations !== null) {
77
+ // JSON is YAML; the CLI's parser reads it unchanged.
78
+ return JSON.stringify(operations, null, 2);
79
+ }
80
+ return undefined;
81
+ };
82
+ const refuse = (message) => ({
83
+ exitCode: 2,
84
+ stdout: '',
85
+ stderr: `${message}\n`,
86
+ });
11
87
  const tools = [
12
88
  {
13
89
  name: 'yarramate_ask',
14
- description: 'The consumed-now read surface. Without a query: orientation — check verdict, drift summary, open-question count, and the backlog in dependency order. With a query: free text matches concept ids, names, and descriptions and returns the connected slice; exact subject ids (document-id#local-id) and projection paths address precisely. Set mode for the roster (subjects), declarable vocabulary (kinds), build order (next), or the full open-questions report (open).',
90
+ description: `The consumed-now read surface. Without a query: orientation — check verdict, drift summary, open-question count, and the backlog in dependency order. With a query: free text matches concept ids, names, and descriptions and returns the connected slice; exact subject ids (document-id#local-id) and projection paths address precisely. Set mode for the roster (subjects), declarable vocabulary (kinds), build order (next), or the full open-questions report (open). ${LOOP}`,
15
91
  inputSchema: {
16
92
  type: 'object',
17
- required: ['workspace'],
18
93
  properties: {
19
94
  ...workspaceProperty,
20
95
  query: {
@@ -33,26 +108,24 @@ const tools = [
33
108
  },
34
109
  },
35
110
  },
36
- arguments: (input) => {
37
- const workspace = String(input.workspace);
111
+ run: (input, context) => withWorkspace(input, context, (workspace, cwd) => {
38
112
  if (typeof input.mode === 'string') {
39
- return ['ask', workspace, `--${input.mode}`, '--json'];
113
+ return runCli(['ask', workspace, `--${input.mode}`, '--json'], cwd);
40
114
  }
41
115
  if (typeof input.query === 'string' && input.query.length > 0) {
42
116
  const budget = typeof input.budget === 'number'
43
117
  ? ['--budget', String(input.budget)]
44
118
  : ['--json'];
45
- return ['ask', workspace, input.query, ...budget];
119
+ return runCli(['ask', workspace, input.query, ...budget], cwd);
46
120
  }
47
- return ['ask', workspace, '--json'];
48
- },
121
+ return runCli(['ask', workspace, '--json'], cwd);
122
+ }),
49
123
  },
50
124
  {
51
125
  name: 'yarramate_design',
52
- description: 'The design interview, one stateless step: the top open question with its subject slice, materiality, and progress. Read-only answers land through the CLI apply command in the repository, not through this server.',
126
+ description: `The design interview, one stateless step: the top open question with its subject slice, materiality, progress, and the operations skeleton that would answer it. Ask the person, land their answer with yarramate_apply, then call this again; the next question is computed from the record, never remembered. A question whose authority is human is for the person to decide, not the agent. ${LOOP}`,
53
127
  inputSchema: {
54
128
  type: 'object',
55
- required: ['workspace'],
56
129
  properties: {
57
130
  ...workspaceProperty,
58
131
  subject: {
@@ -61,61 +134,203 @@ const tools = [
61
134
  },
62
135
  },
63
136
  },
64
- arguments: (input) => [
137
+ run: (input, context) => withWorkspace(input, context, (workspace, cwd) => runCli([
65
138
  'design',
66
- String(input.workspace),
139
+ workspace,
67
140
  ...(typeof input.subject === 'string'
68
141
  ? ['--subject', input.subject]
69
142
  : []),
70
143
  '--json',
71
- ],
144
+ ], cwd)),
145
+ },
146
+ {
147
+ name: 'yarramate_apply',
148
+ description: `Lands answers in the record: one yarramate/operations/v1 document, applied as an atomic batch by the same CLI command a person runs. Any invalid operation refuses the whole batch and nothing is written; the result names each diagnostic with its source location. Writes are spliced into the native documents, never re-serialized, so the diff is exactly the answer. ${LOOP}`,
149
+ inputSchema: {
150
+ type: 'object',
151
+ required: ['operations'],
152
+ properties: {
153
+ ...workspaceProperty,
154
+ operations: {
155
+ description: 'The operations document: `format: yarramate/operations/v1` with an `operations` list. Each operation names its `op` and its `document` (the native document path, for example .yarramate/architecture/main.yaml) and nests the record under the key its op names: add-concept carries `concept: { id, kind, name, description?, status?, ... }`, add-relationship carries `relationship: { id, kind, from, to, description? }`, update-concept and update-relationship carry the same key with only the fields that change, delete-* and rename-* carry the id (and `to` for a rename). Shape, as JSON: {"format":"yarramate/operations/v1","operations":[{"op":"add-concept","document":".yarramate/architecture/main.yaml","concept":{"id":"ops-portal","kind":"applicationComponent","name":"Operations portal"}}]}. YAML or JSON text, or the equivalent JSON object.',
156
+ oneOf: [{ type: 'string' }, { type: 'object' }],
157
+ },
158
+ },
159
+ },
160
+ run: (input, context) => withWorkspace(input, context, (workspace, cwd) => {
161
+ const text = operationsText(input.operations);
162
+ if (text === undefined) {
163
+ return refuse('yarramate_apply needs `operations`: a yarramate/operations/v1 document as YAML or JSON text, or as an object.');
164
+ }
165
+ return withScratch((directory) => {
166
+ const path = join(directory, 'operations.yaml');
167
+ writeFileSync(path, text, 'utf8');
168
+ return runCli(['apply', path, workspace, '--json'], cwd);
169
+ });
170
+ }),
72
171
  },
73
172
  {
74
173
  name: 'yarramate_check',
75
- description: 'Deterministic correctness check of a workspace; returns the machine-readable check result. Never a quality or completeness judgement.',
174
+ description: `Deterministic correctness check of a workspace; returns the machine-readable check result. Never a quality or completeness judgement. Run it after every apply. ${LOOP}`,
76
175
  inputSchema: {
77
176
  type: 'object',
78
- required: ['workspace'],
79
177
  properties: workspaceProperty,
80
178
  },
81
- arguments: (input) => ['check', String(input.workspace), '--json'],
179
+ run: (input, context) => withWorkspace(input, context, (workspace, cwd) => runCli(['check', workspace, '--json'], cwd)),
82
180
  },
83
181
  {
84
182
  name: 'yarramate_reconcile',
85
- description: 'Compare declared architecture with evaluated evidence; returns the reconciliation report with contradicted, unknown, and not-observed findings.',
183
+ description: `Compare declared architecture with evaluated evidence; returns the reconciliation report with contradicted, unknown, and not-observed findings. ${LOOP}`,
86
184
  inputSchema: {
87
185
  type: 'object',
88
- required: ['workspace'],
89
186
  properties: workspaceProperty,
90
187
  },
91
- arguments: (input) => ['reconcile', String(input.workspace)],
188
+ run: (input, context) => withWorkspace(input, context, (workspace, cwd) => runCli(['reconcile', workspace], cwd)),
189
+ },
190
+ {
191
+ name: 'yarramate_export',
192
+ description: `Derives a deliverable from the record and returns it as text: markdown (a projection rendered as prose; needs projection), rtm (the requirements traceability matrix), graph (the compiled semantic graph as JSON), briefs (one brief per subject of a projection; needs projection). xlsx and likec4 write binary or multi-file output, so they need out (and likec4 needs project) and return what was written. ${LOOP}`,
193
+ inputSchema: {
194
+ type: 'object',
195
+ required: ['kind'],
196
+ properties: {
197
+ ...workspaceProperty,
198
+ kind: {
199
+ type: 'string',
200
+ enum: ['markdown', 'rtm', 'graph', 'briefs', 'xlsx', 'likec4'],
201
+ },
202
+ projection: {
203
+ type: 'string',
204
+ description: 'Projection path, relative to the repository root unless absolute; required for markdown, briefs and xlsx',
205
+ },
206
+ out: {
207
+ type: 'string',
208
+ description: 'Where to write, relative to the repository root (the directory that holds .yarramate) unless absolute: a file for markdown, graph and xlsx; a directory for rtm, briefs and likec4. Optional for the text kinds, which then return the text instead.',
209
+ },
210
+ project: {
211
+ type: 'string',
212
+ description: 'The likec4-project.yaml, required for likec4',
213
+ },
214
+ budget: {
215
+ type: 'integer',
216
+ minimum: 1,
217
+ description: 'Approximate token budget per brief (briefs only)',
218
+ },
219
+ },
220
+ },
221
+ run: (input, context) => withWorkspace(input, context, (workspace, cwd) => {
222
+ const kind = typeof input.kind === 'string' ? input.kind : '';
223
+ const projection = typeof input.projection === 'string' ? input.projection : undefined;
224
+ const out = typeof input.out === 'string' ? input.out : undefined;
225
+ const budget = typeof input.budget === 'number'
226
+ ? ['--budget', String(input.budget)]
227
+ : [];
228
+ if (kind === 'markdown' || kind === 'graph') {
229
+ if (kind === 'markdown' && projection === undefined) {
230
+ return refuse('yarramate_export markdown needs `projection`: the path of the view to render.');
231
+ }
232
+ return runCli([
233
+ 'export',
234
+ kind,
235
+ ...(projection === undefined ? [] : [projection]),
236
+ workspace,
237
+ ...(out === undefined ? [] : ['--out', out]),
238
+ ], cwd);
239
+ }
240
+ if (kind === 'rtm') {
241
+ if (out !== undefined) {
242
+ return runCli(['export', 'rtm', workspace, '--out', out], cwd);
243
+ }
244
+ return withScratch((directory) => {
245
+ const result = runCli(['export', 'rtm', workspace, '--out', directory], cwd);
246
+ if (result.exitCode !== 0)
247
+ return result;
248
+ return {
249
+ exitCode: 0,
250
+ stdout: readFileSync(join(directory, 'RTM.md'), 'utf8'),
251
+ stderr: '',
252
+ };
253
+ });
254
+ }
255
+ if (kind === 'briefs') {
256
+ if (projection === undefined) {
257
+ return refuse('yarramate_export briefs needs `projection`: the path of the view whose subjects get a brief each.');
258
+ }
259
+ if (out !== undefined) {
260
+ return runCli([
261
+ 'export',
262
+ 'briefs',
263
+ projection,
264
+ workspace,
265
+ '--out',
266
+ out,
267
+ ...budget,
268
+ ], cwd);
269
+ }
270
+ return withScratch((directory) => {
271
+ const result = runCli([
272
+ 'export',
273
+ 'briefs',
274
+ projection,
275
+ workspace,
276
+ '--out',
277
+ directory,
278
+ ...budget,
279
+ ], cwd);
280
+ if (result.exitCode !== 0)
281
+ return result;
282
+ const files = readdirSync(directory).sort();
283
+ return {
284
+ exitCode: 0,
285
+ stdout: files
286
+ .map((file) => `<!-- ${file} -->\n${readFileSync(join(directory, file), 'utf8')}`)
287
+ .join('\n'),
288
+ stderr: '',
289
+ };
290
+ });
291
+ }
292
+ if (kind === 'xlsx') {
293
+ if (projection === undefined || out === undefined) {
294
+ return refuse('yarramate_export xlsx needs `projection` and `out`: the view to export and the .xlsx path to write; a workbook is binary and cannot come back as text.');
295
+ }
296
+ return runCli(['export', 'xlsx', projection, workspace, '--out', out], cwd);
297
+ }
298
+ if (kind === 'likec4') {
299
+ const project = typeof input.project === 'string' ? input.project : undefined;
300
+ if (project === undefined || out === undefined) {
301
+ return refuse('yarramate_export likec4 needs `project` (the likec4-project.yaml) and `out` (the directory to write).');
302
+ }
303
+ return runCli(['export', 'likec4', project, out, workspace], cwd);
304
+ }
305
+ return refuse('yarramate_export needs `kind`: one of markdown, rtm, graph, briefs, xlsx, likec4.');
306
+ }),
92
307
  },
93
308
  ];
309
+ /** The tool list a client sees; exported so a test can read it without stdio. */
310
+ export const toolCatalogue = tools.map(({ name, description, inputSchema }) => ({
311
+ name,
312
+ description,
313
+ inputSchema,
314
+ }));
94
315
  const respond = (id, result) => {
95
316
  process.stdout.write(`${JSON.stringify({ jsonrpc: '2.0', id, result })}\n`);
96
317
  };
97
318
  const respondError = (id, code, message) => {
98
319
  process.stdout.write(`${JSON.stringify({ jsonrpc: '2.0', id, error: { code, message } })}\n`);
99
320
  };
100
- export const handleRequest = (request) => {
321
+ export const handleRequest = (request, context = { cwd: process.cwd(), workspace: undefined }) => {
101
322
  const id = request.id ?? null;
102
323
  if (request.method === 'initialize') {
103
324
  respond(id, {
104
325
  protocolVersion: '2025-06-18',
105
326
  capabilities: { tools: {} },
106
327
  serverInfo: { name: 'yarramate', version: packageVersion },
107
- instructions: 'Read-only architecture context for YarraMate workspaces. The native documents in the repository remain canonical; this server never mutates them.',
328
+ instructions: `The architecture record of a YarraMate workspace. The native documents in the repository are canonical; every read renders them, and yarramate_apply is the one write, the same atomic batch the CLI lands. ${LOOP}`,
108
329
  });
109
330
  return;
110
331
  }
111
332
  if (request.method === 'tools/list') {
112
- respond(id, {
113
- tools: tools.map(({ name, description, inputSchema }) => ({
114
- name,
115
- description,
116
- inputSchema,
117
- })),
118
- });
333
+ respond(id, { tools: toolCatalogue });
119
334
  return;
120
335
  }
121
336
  if (request.method === 'tools/call') {
@@ -129,7 +344,7 @@ export const handleRequest = (request) => {
129
344
  const input = typeof params.arguments === 'object' && params.arguments !== null
130
345
  ? params.arguments
131
346
  : {};
132
- const result = runCli([...tool.arguments(input)], process.cwd());
347
+ const result = tool.run(input, context);
133
348
  respond(id, {
134
349
  content: [
135
350
  {
@@ -147,6 +362,27 @@ export const handleRequest = (request) => {
147
362
  respondError(id, -32601, `Method "${request.method}" not found`);
148
363
  }
149
364
  };
365
+ /** `yarramate-mcp [--workspace <path>]`; anything else is refused with usage. */
366
+ export const parseServerOptions = (argv) => {
367
+ let workspace;
368
+ for (let index = 0; index < argv.length; index += 1) {
369
+ const option = argv[index];
370
+ if (option === '--workspace') {
371
+ const value = argv[index + 1];
372
+ if (value === undefined ||
373
+ value.startsWith('-') ||
374
+ workspace !== undefined) {
375
+ return undefined;
376
+ }
377
+ workspace = value;
378
+ index += 1;
379
+ continue;
380
+ }
381
+ return undefined;
382
+ }
383
+ return { workspace };
384
+ };
385
+ export const serverUsage = 'Usage:\n yarramate-mcp [--workspace <workspace.yaml>]\n yarramate-mcp --version\n';
150
386
  if (isMainModule(import.meta.url, process.argv[1])) {
151
387
  if (process.argv[2] === '--version') {
152
388
  const result = versionResult('yarramate-mcp');
@@ -154,25 +390,36 @@ if (isMainModule(import.meta.url, process.argv[1])) {
154
390
  process.exitCode = result.exitCode;
155
391
  }
156
392
  else {
157
- const lines = createInterface({ input: process.stdin });
158
- lines.on('line', (line) => {
159
- const text = line.trim();
160
- if (text.length === 0)
161
- return;
162
- let request;
163
- try {
164
- request = JSON.parse(text);
165
- }
166
- catch {
167
- respondError(null, -32700, 'Parse error');
168
- return;
169
- }
170
- try {
171
- handleRequest(request);
172
- }
173
- catch (error) {
174
- respondError(request.id ?? null, -32603, error instanceof Error ? error.message : String(error));
175
- }
176
- });
393
+ const options = parseServerOptions(process.argv.slice(2));
394
+ if (options === undefined) {
395
+ process.stderr.write(serverUsage);
396
+ process.exitCode = 2;
397
+ }
398
+ else {
399
+ const context = {
400
+ cwd: process.cwd(),
401
+ workspace: options.workspace,
402
+ };
403
+ const lines = createInterface({ input: process.stdin });
404
+ lines.on('line', (line) => {
405
+ const text = line.trim();
406
+ if (text.length === 0)
407
+ return;
408
+ let request;
409
+ try {
410
+ request = JSON.parse(text);
411
+ }
412
+ catch {
413
+ respondError(null, -32700, 'Parse error');
414
+ return;
415
+ }
416
+ try {
417
+ handleRequest(request, context);
418
+ }
419
+ catch (error) {
420
+ respondError(request.id ?? null, -32603, error instanceof Error ? error.message : String(error));
421
+ }
422
+ });
423
+ }
177
424
  }
178
425
  }
@@ -118,6 +118,29 @@ export interface VisualChoiceSelectedPayload {
118
118
  readonly choiceId: string;
119
119
  readonly optionId: string;
120
120
  }
121
+ /**
122
+ * The reviewer hands one open question to the agent (#515, ADR 0151). The
123
+ * phrasing rides along so the transcript, the journal and the agent read the
124
+ * same words; the agent still computes the step from the record, because the
125
+ * question is a reading of the model at one moment and the model may have
126
+ * moved since the row was drawn.
127
+ */
128
+ export interface VisualQuestionDelegatePayload {
129
+ readonly questionId: string;
130
+ /** Null for a workspace-scoped question, which names no subject. */
131
+ readonly subjectId: string | null;
132
+ readonly question: string;
133
+ }
134
+ /**
135
+ * The agent's answer to a delegated question: operations it PROPOSES, which
136
+ * the browser stages into the reviewer's changeset and the reviewer commits
137
+ * (ADR 0151). The agent never writes (ADR 0084, ADR 0088); this is how it
138
+ * answers without doing so. `note` is the line the transcript shows.
139
+ */
140
+ export interface VisualOperationsProposePayload {
141
+ readonly note: string;
142
+ readonly operations: readonly YarramateOperation[];
143
+ }
121
144
  export interface VisualViewNavigatePayload {
122
145
  readonly viewId: string;
123
146
  readonly requiresAttention: boolean;
@@ -410,6 +433,10 @@ export type VisualBrowserInput = {
410
433
  readonly type: "session.end";
411
434
  readonly lastAcknowledgedSequence: number;
412
435
  readonly payload: VisualBrowserSessionEndPayload;
436
+ } | {
437
+ readonly type: "question.delegate";
438
+ readonly lastAcknowledgedSequence: number;
439
+ readonly payload: VisualQuestionDelegatePayload;
413
440
  };
414
441
  interface VisualEventEnvelope<Type extends string, Payload> {
415
442
  readonly format: "yarramate/visual-event/v1";
@@ -420,7 +447,7 @@ interface VisualEventEnvelope<Type extends string, Payload> {
420
447
  readonly timestamp: string;
421
448
  readonly payload: Payload;
422
449
  }
423
- export type VisualEvent = VisualEventEnvelope<"chat.message", VisualChatMessagePayload> | VisualEventEnvelope<"choice.selected", VisualChoiceSelectedPayload> | VisualEventEnvelope<"view.navigate", VisualViewNavigatePayload> | VisualEventEnvelope<"session.end", VisualSessionEndPayload> | VisualEventEnvelope<"browser.connected", VisualBrowserConnectedPayload> | VisualEventEnvelope<"browser.disconnected", VisualBrowserDisconnectedPayload> | VisualEventEnvelope<"filter.query", VisualFilterQueryPayload> | VisualEventEnvelope<"changeset.commit", VisualChangesetCommitPayload> | VisualEventEnvelope<"layout.save", VisualLayoutSavePayload>;
450
+ export type VisualEvent = VisualEventEnvelope<"chat.message", VisualChatMessagePayload> | VisualEventEnvelope<"choice.selected", VisualChoiceSelectedPayload> | VisualEventEnvelope<"question.delegate", VisualQuestionDelegatePayload> | VisualEventEnvelope<"view.navigate", VisualViewNavigatePayload> | VisualEventEnvelope<"session.end", VisualSessionEndPayload> | VisualEventEnvelope<"browser.connected", VisualBrowserConnectedPayload> | VisualEventEnvelope<"browser.disconnected", VisualBrowserDisconnectedPayload> | VisualEventEnvelope<"filter.query", VisualFilterQueryPayload> | VisualEventEnvelope<"changeset.commit", VisualChangesetCommitPayload> | VisualEventEnvelope<"layout.save", VisualLayoutSavePayload>;
424
451
  /**
425
452
  * The filter a chat turn resolved to. The agent states `query` and nothing
426
453
  * else: the runtime evaluates it against the same graph a `filter.query`
@@ -499,7 +526,7 @@ interface VisualResponseEnvelope<Type extends string, Payload> {
499
526
  readonly timestamp: string;
500
527
  readonly payload: Payload;
501
528
  }
502
- export type VisualResponse = VisualResponseEnvelope<"chat.response", VisualChatResponsePayload> | VisualResponseEnvelope<"agent.status", VisualAgentStatusPayload> | VisualResponseEnvelope<"choice.present", VisualChoicePresentPayload> | VisualResponseEnvelope<"handoff.complete", VisualHandoffSummary> | VisualResponseEnvelope<"diagnostic", VisualDiagnosticPayload>;
529
+ export type VisualResponse = VisualResponseEnvelope<"chat.response", VisualChatResponsePayload> | VisualResponseEnvelope<"agent.status", VisualAgentStatusPayload> | VisualResponseEnvelope<"choice.present", VisualChoicePresentPayload> | VisualResponseEnvelope<"operations.propose", VisualOperationsProposePayload> | VisualResponseEnvelope<"handoff.complete", VisualHandoffSummary> | VisualResponseEnvelope<"diagnostic", VisualDiagnosticPayload>;
503
530
  export type VisualHandoffDecision = "completed" | "cancelled" | "failed";
504
531
  export type VisualTerminationReason = "user-ended" | "child-failed" | "browser-timeout" | "main-cancelled" | "server-failed" | "compiler-failed";
505
532
  export interface VisualHandoff extends VisualHandoffSummary {
@@ -72,6 +72,7 @@ const CONTENT_TYPES = {
72
72
  const TURN_COMPLETING = {
73
73
  "chat.response": true,
74
74
  "choice.present": true,
75
+ "operations.propose": true,
75
76
  "handoff.complete": true,
76
77
  diagnostic: true,
77
78
  };
@@ -272,6 +273,8 @@ const eventFrom = (input, envelope) => {
272
273
  return { ...envelope, type: input.type, payload: input.payload };
273
274
  case "choice.selected":
274
275
  return { ...envelope, type: input.type, payload: input.payload };
276
+ case "question.delegate":
277
+ return { ...envelope, type: input.type, payload: input.payload };
275
278
  case "view.navigate":
276
279
  return { ...envelope, type: input.type, payload: input.payload };
277
280
  case "session.end":
@@ -836,7 +839,9 @@ export const startVisualServer = async (options) => {
836
839
  const recordEvent = (event) => {
837
840
  // A session on its way out waits on nobody, and asking past the question
838
841
  // is how a reviewer declines to answer it.
839
- if (event.type === "chat.message" || event.type === "session.end") {
842
+ if (event.type === "chat.message" ||
843
+ event.type === "question.delegate" ||
844
+ event.type === "session.end") {
840
845
  pendingChoice = null;
841
846
  }
842
847
  if (event.type === "session.end")
@@ -849,6 +854,15 @@ export const startVisualServer = async (options) => {
849
854
  });
850
855
  return;
851
856
  }
857
+ if (event.type === "question.delegate") {
858
+ // The line the reviewer sees for handing a question over (#515).
859
+ transcript.push({
860
+ id: event.eventId,
861
+ speaker: "reviewer",
862
+ text: `Answer via your agent: ${event.payload.question}`,
863
+ });
864
+ return;
865
+ }
852
866
  if (event.type !== "choice.selected")
853
867
  return;
854
868
  if (pendingChoice?.choiceId === event.payload.choiceId)
@@ -874,9 +888,11 @@ export const startVisualServer = async (options) => {
874
888
  }
875
889
  const text = response.type === "chat.response"
876
890
  ? response.payload.text
877
- : response.type === "handoff.complete"
878
- ? response.payload.summary
879
- : undefined;
891
+ : response.type === "operations.propose"
892
+ ? response.payload.note
893
+ : response.type === "handoff.complete"
894
+ ? response.payload.summary
895
+ : undefined;
880
896
  if (text === undefined)
881
897
  return;
882
898
  transcript.push({ id: response.responseId, speaker: "agent", text });
@@ -1157,7 +1173,10 @@ export const startVisualServer = async (options) => {
1157
1173
  }
1158
1174
  // A session that was started without a conversation has no chat and no
1159
1175
  // choices to make; the diagram, and moving around it, is all it granted.
1160
- const granted = input.value.type === "chat.message"
1176
+ // Delegating a question is a chat turn by another door (#515): it
1177
+ // needs the same agent the composer needs.
1178
+ const granted = input.value.type === "chat.message" ||
1179
+ input.value.type === "question.delegate"
1161
1180
  ? capabilities.chat
1162
1181
  : input.value.type === "choice.selected"
1163
1182
  ? capabilities.choices
@@ -1677,7 +1696,9 @@ export const startVisualServer = async (options) => {
1677
1696
  // The agent cannot hold a conversation a diagram-only session never
1678
1697
  // offered; the browser has nowhere to show one.
1679
1698
  if (!capabilities.chat &&
1680
- (response.type === "chat.response" || response.type === "choice.present")) {
1699
+ (response.type === "chat.response" ||
1700
+ response.type === "choice.present" ||
1701
+ response.type === "operations.propose")) {
1681
1702
  respondJson(server, 409, {
1682
1703
  accepted: false,
1683
1704
  diagnostics: [