@motionode/cli 0.1.2 → 0.2.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/lib/inbox.js ADDED
@@ -0,0 +1,75 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * The local signal inbox: <repo>/.motionode/inbox.json.
5
+ * `motionode signals watch` writes signals here the moment Motionode pushes them;
6
+ * the hook and the MCP server read it locally (instant, no network, no model).
7
+ * A signal leaves the inbox when it is acknowledged (MCP ack_signal).
8
+ */
9
+
10
+ const fs = require('fs');
11
+ const path = require('path');
12
+ const { execFileSync } = require('child_process');
13
+
14
+ function repoRoot(dir = process.cwd()) {
15
+ try {
16
+ return execFileSync('git', ['rev-parse', '--show-toplevel'], { cwd: dir, stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim() || dir;
17
+ } catch (_e) {
18
+ return dir;
19
+ }
20
+ }
21
+
22
+ function inboxPath(dir) {
23
+ return path.join(repoRoot(dir), '.motionode', 'inbox.json');
24
+ }
25
+
26
+ /** Keep .motionode/ out of git without touching the repo's tracked .gitignore. */
27
+ function ensureExcluded(dir) {
28
+ try {
29
+ const root = repoRoot(dir);
30
+ const exclude = path.join(root, '.git', 'info', 'exclude');
31
+ if (!fs.existsSync(path.dirname(exclude))) return;
32
+ const current = fs.existsSync(exclude) ? fs.readFileSync(exclude, 'utf8') : '';
33
+ if (!/^\.motionode\/?$/m.test(current)) fs.appendFileSync(exclude, `${current.endsWith('\n') || !current ? '' : '\n'}.motionode/\n`);
34
+ } catch (_e) { /* best effort */ }
35
+ }
36
+
37
+ function read(dir) {
38
+ try {
39
+ const data = JSON.parse(fs.readFileSync(inboxPath(dir), 'utf8'));
40
+ return Array.isArray(data?.signals) ? data.signals : [];
41
+ } catch (_e) {
42
+ return [];
43
+ }
44
+ }
45
+
46
+ function write(dir, signals) {
47
+ const file = inboxPath(dir);
48
+ fs.mkdirSync(path.dirname(file), { recursive: true });
49
+ const tmp = `${file}.${process.pid}.tmp`;
50
+ fs.writeFileSync(tmp, JSON.stringify({ updatedAt: new Date().toISOString(), signals }, null, 2));
51
+ fs.renameSync(tmp, file);
52
+ }
53
+
54
+ function ticketRefOf(signal) {
55
+ if (signal?.toRoom?.kind === 'ticket') return signal.toRoom.uuid;
56
+ if (signal?.fromRoom?.kind === 'ticket') return signal.fromRoom.uuid;
57
+ return signal?.ticketRef || null;
58
+ }
59
+
60
+ function add(dir, signal) {
61
+ const list = read(dir);
62
+ if (list.some((s) => s.id === signal.id)) return false;
63
+ list.push({ ...signal, ticketRef: ticketRefOf(signal), receivedAt: new Date().toISOString() });
64
+ write(dir, list.slice(-200));
65
+ return true;
66
+ }
67
+
68
+ function remove(dir, id) {
69
+ const list = read(dir);
70
+ const next = list.filter((s) => Number(s.id) !== Number(id));
71
+ if (next.length !== list.length) write(dir, next);
72
+ return next.length !== list.length;
73
+ }
74
+
75
+ module.exports = { repoRoot, inboxPath, ensureExcluded, read, write, add, remove, ticketRefOf };
package/lib/mcp.js ADDED
@@ -0,0 +1,327 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * `motionode mcp`: a stdio MCP server so Claude Code, Cursor or any MCP client
5
+ * can work a Motionode ticket as a first-class worker (team-coordination spec §4.3).
6
+ * Every tool calls Motionode's /api/v1/agent as the connected person; Motionode
7
+ * applies the same permission checks as the web app.
8
+ *
9
+ * Transport: newline-delimited JSON-RPC 2.0 on stdin/stdout. Logs go to stderr only.
10
+ */
11
+
12
+ const readline = require('readline');
13
+ const pkg = require('../package.json');
14
+ const { agentClient } = require('./agent_api');
15
+ const Inbox = require('./inbox');
16
+
17
+ const FALLBACK_PROTOCOL = '2025-06-18';
18
+ const DISCOVER_VERSIONS = ['2026-07-28'];
19
+
20
+ const TOOLS = [
21
+ {
22
+ name: 'my_work',
23
+ description: 'Tickets assigned to me in Motionode, with their current step and how many signals are waiting.',
24
+ inputSchema: { type: 'object', properties: {}, additionalProperties: false },
25
+ run: (api) => api.get('/my_work'),
26
+ },
27
+ {
28
+ name: 'get_ticket',
29
+ description: 'One ticket: description, acceptance criteria, current step, branches per service, and pending signals. ref = ticket id like T11-604 or its uuid.',
30
+ inputSchema: { type: 'object', properties: { ref: { type: 'string' } }, required: ['ref'], additionalProperties: false },
31
+ run: (api, a) => api.get(`/ticket/${encodeURIComponent(a.ref)}`),
32
+ },
33
+ {
34
+ name: 'get_signals',
35
+ description: 'Signals for a ticket that I have not resolved yet (requirement changes, questions answered, overlaps, conflicts, failed checks). Check these at every task boundary.',
36
+ inputSchema: { type: 'object', properties: { ref: { type: 'string' } }, required: ['ref'], additionalProperties: false },
37
+ run: (api, a) => api.get(`/ticket/${encodeURIComponent(a.ref)}/signals`),
38
+ },
39
+ {
40
+ name: 'ack_signal',
41
+ description: 'Acknowledge a signal. Set incorporated=true (with how = one sentence) only once your work actually reflects it.',
42
+ inputSchema: {
43
+ type: 'object',
44
+ properties: { ref: { type: 'string' }, id: { type: 'number' }, incorporated: { type: 'boolean' }, how: { type: 'string' } },
45
+ required: ['ref', 'id'],
46
+ additionalProperties: false,
47
+ },
48
+ run: async (api, a) => {
49
+ const out = await api.post(`/ticket/${encodeURIComponent(a.ref)}/signals/${Number(a.id)}/ack`, { incorporated: !!a.incorporated, how: a.how || null });
50
+ Inbox.remove(process.cwd(), a.id);
51
+ return out;
52
+ },
53
+ },
54
+ {
55
+ name: 'start_step',
56
+ description: 'Start my assigned step on the ticket and register this session as its worker.',
57
+ inputSchema: { type: 'object', properties: { ref: { type: 'string' } }, required: ['ref'], additionalProperties: false },
58
+ run: (api, a) => api.post(`/ticket/${encodeURIComponent(a.ref)}/start_step`, {}),
59
+ },
60
+ {
61
+ name: 'report_progress',
62
+ description: 'One short line about what you are doing now (shown to the team; not a log).',
63
+ inputSchema: { type: 'object', properties: { ref: { type: 'string' }, text: { type: 'string' }, percent: { type: 'number' } }, required: ['ref', 'text'], additionalProperties: false },
64
+ run: (api, a) => api.post(`/ticket/${encodeURIComponent(a.ref)}/progress`, { text: a.text, percent: a.percent ?? null }),
65
+ },
66
+ {
67
+ name: 'ask_question',
68
+ description: 'Ask a question you cannot answer from the ticket. Motionode routes it (rules, the project manager, or the client) and the answer arrives as a signal.',
69
+ inputSchema: { type: 'object', properties: { ref: { type: 'string' }, question: { type: 'string' } }, required: ['ref', 'question'], additionalProperties: false },
70
+ run: (api, a) => api.post(`/ticket/${encodeURIComponent(a.ref)}/question`, { question: a.question }),
71
+ },
72
+ {
73
+ name: 'report_discovery',
74
+ description: 'You found something that may affect other work (a changed contract, a new status, a shared function). Motionode tells the tickets that change the same files, and the topic the work came from.',
75
+ inputSchema: { type: 'object', properties: { ref: { type: 'string' }, text: { type: 'string' }, files: { type: 'array', items: { type: 'string' } } }, required: ['ref', 'text'], additionalProperties: false },
76
+ run: (api, a) => api.post(`/ticket/${encodeURIComponent(a.ref)}/discovery`, { text: a.text, files: a.files || [] }),
77
+ },
78
+ {
79
+ name: 'request_review',
80
+ description: 'You finished the step: propose completion with a short summary. A person accepts it in Motionode.',
81
+ inputSchema: { type: 'object', properties: { ref: { type: 'string' }, summary: { type: 'string' } }, required: ['ref', 'summary'], additionalProperties: false },
82
+ run: (api, a) => api.post(`/ticket/${encodeURIComponent(a.ref)}/review`, { summary: a.summary }),
83
+ },
84
+ // ---- C1 coordination records: claims, observations, decisions, blockers, evidence (team-coordination C1) ----
85
+ {
86
+ name: 'claim_step',
87
+ description: 'Claim the ticket\'s current step before working on it (and start it). Exclusive: if another worker holds it you get who and until when. The claim is a lease (30 min default) that every call you make on the ticket renews; release it when you stop. Needs MOTIONODE_AGENT (your agent name).',
88
+ inputSchema: { type: 'object', properties: { ref: { type: 'string' }, lease_min: { type: 'number' }, note: { type: 'string' } }, required: ['ref'], additionalProperties: false },
89
+ run: (api, a) => api.post(`/ticket/${encodeURIComponent(a.ref)}/claim`, { lease_min: a.lease_min ?? null, note: a.note || null }),
90
+ },
91
+ {
92
+ name: 'release_step',
93
+ description: 'Release your claim on the ticket\'s step: reason "done" (your part is finished) or "handoff" (someone else should take it; say why in note).',
94
+ inputSchema: { type: 'object', properties: { ref: { type: 'string' }, reason: { type: 'string', enum: ['done', 'handoff'] }, note: { type: 'string' } }, required: ['ref', 'reason'], additionalProperties: false },
95
+ run: (api, a) => api.post(`/ticket/${encodeURIComponent(a.ref)}/release`, { reason: a.reason, note: a.note || null }),
96
+ },
97
+ {
98
+ name: 'report_observation',
99
+ description: 'Record something you or a tester SAW (a symptom), with its evidence: steps, env, sha, screenshots, preview url. It stays on this ticket; the coordinator links it to the one root-cause ticket.',
100
+ inputSchema: { type: 'object', properties: { ref: { type: 'string' }, text: { type: 'string' }, title: { type: 'string' }, evidence: { type: 'object' } }, required: ['ref', 'text'], additionalProperties: false },
101
+ run: (api, a) => api.post(`/ticket/${encodeURIComponent(a.ref)}/observation`, { text: a.text, title: a.title || null, evidence: a.evidence || null }),
102
+ },
103
+ {
104
+ name: 'report_finding',
105
+ description: 'Record a cause. verified=true only when established by evidence that holds (not suspected); root_cause=true when it names the one accountable problem.',
106
+ inputSchema: { type: 'object', properties: { ref: { type: 'string' }, text: { type: 'string' }, title: { type: 'string' }, verified: { type: 'boolean' }, root_cause: { type: 'boolean' }, evidence: { type: 'object' } }, required: ['ref', 'text'], additionalProperties: false },
107
+ run: (api, a) => api.post(`/ticket/${encodeURIComponent(a.ref)}/finding`, { text: a.text, title: a.title || null, verified: a.verified === true, root_cause: a.root_cause === true, evidence: a.evidence || null }),
108
+ },
109
+ {
110
+ name: 'link_observation',
111
+ description: 'Coordinator/manager: this ticket is the root cause of that observation (its uuid, on any ticket of the project). note = why. undo=true reverses it.',
112
+ inputSchema: { type: 'object', properties: { ref: { type: 'string' }, observation: { type: 'string' }, note: { type: 'string' }, undo: { type: 'boolean' } }, required: ['ref', 'observation', 'note'], additionalProperties: false },
113
+ run: (api, a) => api.post(`/ticket/${encodeURIComponent(a.ref)}/link_observation`, { observation: a.observation, note: a.note, undo: a.undo === true }),
114
+ },
115
+ {
116
+ name: 'relate_work',
117
+ description: 'Coordinator/manager: kind "duplicate_of" (this ticket is the same problem as other ticket) or "affects" (this root cause also affects other: a ticket, request or topic, whose people are told). note = why. undo=true reverses it.',
118
+ inputSchema: {
119
+ type: 'object',
120
+ properties: { ref: { type: 'string' }, kind: { type: 'string', enum: ['duplicate_of', 'affects'] }, other_type: { type: 'string', enum: ['ticket', 'request', 'topic'] }, other_ref: { type: 'string' }, note: { type: 'string' }, undo: { type: 'boolean' } },
121
+ required: ['ref', 'kind', 'other_type', 'other_ref', 'note'],
122
+ additionalProperties: false,
123
+ },
124
+ run: (api, a) => api.post(`/ticket/${encodeURIComponent(a.ref)}/relate`, { kind: a.kind, other: { type: a.other_type, ref: a.other_ref }, note: a.note, undo: a.undo === true }),
125
+ },
126
+ {
127
+ name: 'ask_decision',
128
+ description: 'A decision only a person can take, with at least two options (label + consequence). One owner (a user uuid; default the project manager), due in due_hours (default 24). Past due it is re-sent. The choice arrives as a "decided" signal.',
129
+ inputSchema: {
130
+ type: 'object',
131
+ properties: {
132
+ ref: { type: 'string' },
133
+ question: { type: 'string' },
134
+ options: { type: 'array', items: { type: 'object', properties: { key: { type: 'string' }, label: { type: 'string' }, consequence: { type: 'string' } }, required: ['label'] } },
135
+ owner: { type: 'string' },
136
+ backup: { type: 'string' },
137
+ due_hours: { type: 'number' },
138
+ },
139
+ required: ['ref', 'question', 'options'],
140
+ additionalProperties: false,
141
+ },
142
+ run: (api, a) => api.post(`/ticket/${encodeURIComponent(a.ref)}/decision`, { question: a.question, options: a.options, owner: a.owner || null, backup: a.backup || null, due_hours: a.due_hours ?? null }),
143
+ },
144
+ {
145
+ name: 'choose_decision',
146
+ description: 'Record the option taken on a decision (its uuid) -- only its owner, an addressee or a project decider may.',
147
+ inputSchema: { type: 'object', properties: { decision: { type: 'string' }, option: { type: 'string' }, note: { type: 'string' } }, required: ['decision', 'option'], additionalProperties: false },
148
+ run: (api, a) => api.post(`/decision/${encodeURIComponent(a.decision)}/choose`, { option: a.option, note: a.note || null }),
149
+ },
150
+ {
151
+ name: 'report_blocker',
152
+ description: 'The current step cannot go on. It goes Waiting with your reason. Say what it waits on: blocking_ticket (another ticket, a real dependency) or decision {question, options} (a person\'s choice). category: CLIENT, VENDOR, INTERNAL, DEPENDENCY or OTHER.',
153
+ inputSchema: {
154
+ type: 'object',
155
+ properties: {
156
+ ref: { type: 'string' },
157
+ reason: { type: 'string' },
158
+ category: { type: 'string', enum: ['CLIENT', 'VENDOR', 'INTERNAL', 'DEPENDENCY', 'OTHER'] },
159
+ blocking_ticket: { type: 'string' },
160
+ decision: { type: 'object', properties: { question: { type: 'string' }, options: { type: 'array' }, owner: { type: 'string' }, due_hours: { type: 'number' } }, required: ['question', 'options'] },
161
+ },
162
+ required: ['ref', 'reason'],
163
+ additionalProperties: false,
164
+ },
165
+ run: (api, a) => api.post(`/ticket/${encodeURIComponent(a.ref)}/blocker`, { reason: a.reason, category: a.category || null, ticket: a.blocking_ticket || null, decision: a.decision || null }),
166
+ },
167
+ {
168
+ name: 'submit_evidence',
169
+ description: 'Evidence your fix rests on, bound to a commit. kind fails_without: data {test, command, without:{sha, output}, with:{sha, output}}. suite_baseline: sha + data {suites, before:{passed,failed}, after:{passed,failed}}. preview_check: record = the browser-check run id. tester_recheck: record = a person\'s QA result id (you cannot be the tester). Returns the ticket\'s readiness.',
170
+ inputSchema: {
171
+ type: 'object',
172
+ properties: { ref: { type: 'string' }, kind: { type: 'string', enum: ['fails_without', 'suite_baseline', 'preview_check', 'tester_recheck'] }, sha: { type: 'string' }, record: { type: 'string' }, note: { type: 'string' }, data: { type: 'object' } },
173
+ required: ['ref', 'kind'],
174
+ additionalProperties: false,
175
+ },
176
+ run: (api, a) => api.post(`/ticket/${encodeURIComponent(a.ref)}/evidence`, { kind: a.kind, sha: a.sha || null, ref: a.record || null, note: a.note || null, data: a.data || null }),
177
+ },
178
+ {
179
+ name: 'open_hotfix',
180
+ description: 'Project manager / owner only: ship one verified fix to staging ahead of the batch (a one-member release, built at once). reason = why it cannot wait. Needs fails_without + a clean suite_baseline; the person\'s re-check may follow the ship.',
181
+ inputSchema: { type: 'object', properties: { ref: { type: 'string' }, reason: { type: 'string' } }, required: ['ref', 'reason'], additionalProperties: false },
182
+ run: (api, a) => api.post(`/ticket/${encodeURIComponent(a.ref)}/hotfix`, { reason: a.reason }),
183
+ },
184
+ // C1-031: file work and keep project notes (project = uuid or exact name).
185
+ {
186
+ name: 'list_deliverables',
187
+ description: 'A project\'s deliverables (display id, title, uuid), to file work under. project = its uuid or exact name.',
188
+ inputSchema: { type: 'object', properties: { project: { type: 'string' } }, required: ['project'], additionalProperties: false },
189
+ run: (api, a) => api.get(`/project/${encodeURIComponent(a.project)}/deliverables`),
190
+ },
191
+ {
192
+ name: 'create_work_item',
193
+ description: 'File a work item (a ticket in setup; nothing starts). An open ticket with the same title refuses with that ticket: add to it instead, or pass confirmed_new if this is genuinely separate. Related open work comes back as similar. deliverable = display id, uuid or title (default: the project\'s Interrupt deliverable); new_deliverable = a deliverable by name (reused if the project has one).',
194
+ inputSchema: {
195
+ type: 'object',
196
+ properties: { project: { type: 'string' }, title: { type: 'string' }, description: { type: 'string' }, deliverable: { type: 'string' }, new_deliverable: { type: 'string' }, acceptance_criteria: { type: 'array', items: { type: 'string' } }, confirmed_new: { type: 'boolean' } },
197
+ required: ['project', 'title'],
198
+ additionalProperties: false,
199
+ },
200
+ run: (api, a) => api.post(`/project/${encodeURIComponent(a.project)}/work_items`, {
201
+ title: a.title, description: a.description || '', deliverable: a.deliverable || null, newDeliverable: a.new_deliverable || null,
202
+ acceptanceCriteria: a.acceptance_criteria || [], confirmedNew: a.confirmed_new === true,
203
+ }),
204
+ },
205
+ {
206
+ name: 'update_work_item',
207
+ description: 'Change a work item\'s title and/or description, as me (I need edit rights on its project). ref = its code (T2-21) or uuid. Status changes aren\'t available yet: Motionode has no cancel or close for a work item.',
208
+ inputSchema: {
209
+ type: 'object',
210
+ properties: { project: { type: 'string' }, ref: { type: 'string' }, title: { type: 'string' }, description: { type: 'string' } },
211
+ required: ['project', 'ref'],
212
+ additionalProperties: false,
213
+ },
214
+ run: (api, a) => api.patch(`/project/${encodeURIComponent(a.project)}/work_items/${encodeURIComponent(a.ref)}`, {
215
+ ...(typeof a.title === 'string' ? { title: a.title } : {}),
216
+ ...(typeof a.description === 'string' ? { description: a.description } : {}),
217
+ }),
218
+ },
219
+ {
220
+ name: 'list_notes',
221
+ description: 'The project notes I may read: uuid, title (first line), where attached, size.',
222
+ inputSchema: { type: 'object', properties: { project: { type: 'string' } }, required: ['project'], additionalProperties: false },
223
+ run: (api, a) => api.get(`/project/${encodeURIComponent(a.project)}/notes`),
224
+ },
225
+ {
226
+ name: 'read_note',
227
+ description: 'One project note: its text and HTML.',
228
+ inputSchema: { type: 'object', properties: { project: { type: 'string' }, uuid: { type: 'string' } }, required: ['project', 'uuid'], additionalProperties: false },
229
+ run: (api, a) => api.get(`/project/${encodeURIComponent(a.project)}/notes/${encodeURIComponent(a.uuid)}`),
230
+ },
231
+ {
232
+ name: 'write_note',
233
+ description: 'Create a project note from markdown, or replace one (uuid given; you must have written it or have it shared with you). deliverable or ticket makes it a team note the project team sees; otherwise it is a board note, shared with share_with (emails or user uuids of the project).',
234
+ inputSchema: {
235
+ type: 'object',
236
+ properties: { project: { type: 'string' }, uuid: { type: 'string' }, markdown: { type: 'string' }, category: { type: 'string' }, deliverable: { type: 'string' }, ticket: { type: 'string' }, share_with: { type: 'array', items: { type: 'string' } } },
237
+ required: ['project', 'markdown'],
238
+ additionalProperties: false,
239
+ },
240
+ run: (api, a) => (a.uuid
241
+ ? api.patch(`/project/${encodeURIComponent(a.project)}/notes/${encodeURIComponent(a.uuid)}`, { markdown: a.markdown, category: a.category || null })
242
+ : api.post(`/project/${encodeURIComponent(a.project)}/notes`, { markdown: a.markdown, category: a.category || null, deliverable: a.deliverable || null, ticket: a.ticket || null, shareWith: a.share_with || [] })),
243
+ },
244
+ ];
245
+
246
+ /** Tools a Motionode older than the CLI may not serve yet (a 404 from them means "not offered", not "not found"). */
247
+ const NEWER_TOOLS = new Set(TOOLS.map((t) => t.name).filter((n) => !['my_work', 'get_ticket', 'get_signals', 'ack_signal'].includes(n)));
248
+
249
+ function send(msg) {
250
+ process.stdout.write(`${JSON.stringify(msg)}\n`);
251
+ }
252
+
253
+ async function handle(msg, api) {
254
+ const { id, method, params } = msg || {};
255
+ if (method === 'initialize') {
256
+ return {
257
+ protocolVersion: params?.protocolVersion || FALLBACK_PROTOCOL,
258
+ capabilities: { tools: {} },
259
+ serverInfo: { name: 'motionode', version: pkg.version },
260
+ instructions: 'Motionode tickets. claim_step before working a step and release_step when you stop. Call get_signals at every task boundary and before finishing; ack_signal with incorporated=true once your work reflects a change. Report what you saw with report_observation, causes with report_finding, blockers with report_blocker, and back every fix with submit_evidence (fails_without + suite_baseline).',
261
+ };
262
+ }
263
+ // Newer MCP handshake (protocol 2026-07-28): server/discover instead of initialize.
264
+ if (method === 'server/discover') {
265
+ return {
266
+ resultType: 'complete',
267
+ supportedVersions: DISCOVER_VERSIONS,
268
+ capabilities: { tools: {} },
269
+ _meta: { 'io.modelcontextprotocol/serverInfo': { name: 'motionode', version: pkg.version } },
270
+ };
271
+ }
272
+ if (method === 'ping') return {};
273
+ if (method === 'tools/list') {
274
+ return { resultType: 'complete', tools: TOOLS.map(({ name, description, inputSchema }) => ({ name, description, inputSchema })) };
275
+ }
276
+ if (method === 'tools/call') {
277
+ const tool = TOOLS.find((t) => t.name === params?.name);
278
+ if (!tool) return { resultType: 'complete', content: [{ type: 'text', text: `Unknown tool ${params?.name}.`, isError: true }], isError: true };
279
+ try {
280
+ const out = await tool.run(api, params?.arguments || {});
281
+ const pending = Inbox.read(process.cwd()).length;
282
+ const text = JSON.stringify({ ...out, signals_pending: pending }, null, 2);
283
+ return { resultType: 'complete', content: [{ type: 'text', text }] };
284
+ } catch (err) {
285
+ const newer = NEWER_TOOLS.has(tool.name);
286
+ const msgText = err.status === 404 && newer && /cannot (get|post)|not found$/i.test(err.message || '')
287
+ ? 'This Motionode does not offer that yet.'
288
+ : err.message;
289
+ return { resultType: 'complete', content: [{ type: 'text', text: msgText, isError: true }], isError: true };
290
+ }
291
+ }
292
+ if (id === undefined || id === null) return undefined; // notification (e.g. notifications/initialized)
293
+ const e = new Error(`Method not found: ${method}`);
294
+ e.code = -32601;
295
+ throw e;
296
+ }
297
+
298
+ function serve(config) {
299
+ const api = agentClient(config);
300
+ const rl = readline.createInterface({ input: process.stdin, terminal: false });
301
+ const inflight = new Set();
302
+ rl.on('line', (line) => {
303
+ const job = onLine(line);
304
+ inflight.add(job);
305
+ job.finally(() => inflight.delete(job));
306
+ });
307
+ async function onLine(line) {
308
+ const trimmed = line.trim();
309
+ if (!trimmed) return;
310
+ let msg;
311
+ try { msg = JSON.parse(trimmed); } catch (_e) {
312
+ send({ jsonrpc: '2.0', id: null, error: { code: -32700, message: 'Parse error' } });
313
+ return;
314
+ }
315
+ try {
316
+ const result = await handle(msg, api);
317
+ if (msg.id !== undefined && msg.id !== null && result !== undefined) send({ jsonrpc: '2.0', id: msg.id, result });
318
+ } catch (err) {
319
+ if (msg.id !== undefined && msg.id !== null) send({ jsonrpc: '2.0', id: msg.id, error: { code: err.code || -32603, message: err.message } });
320
+ }
321
+ }
322
+ // Input closed: answer what is still running, then exit.
323
+ rl.on('close', async () => { await Promise.allSettled([...inflight]); process.exit(0); });
324
+ process.stderr.write(`motionode mcp ${pkg.version} serving ${api.base}\n`);
325
+ }
326
+
327
+ module.exports = { serve, handle, TOOLS };
@@ -0,0 +1,116 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * C1-031: file work and keep project notes from the terminal, as the connected person (same machine token as the rest
5
+ * of the CLI, or MOTIONODE_TOKEN). The project is --project <uuid or exact name>, or MOTIONODE_PROJECT.
6
+ *
7
+ * motionode deliverables --project P
8
+ * motionode work-item create --project P --title T [--description D | --description-file F]
9
+ * [--deliverable R2-1 | --new-deliverable "Name"] [--criteria "a | b"] [--confirmed-new]
10
+ * motionode work-item update <code> --project P [--title T] [--description D | --description-file F] (C1-032)
11
+ * motionode notes list --project P
12
+ * motionode notes get <uuid> --project P [--html]
13
+ * motionode notes create --project P --file F.md [--deliverable R | --ticket T] [--share a@x,b@y] [--category C]
14
+ * motionode notes update <uuid> --project P --file F.md
15
+ */
16
+
17
+ const fs = require('fs');
18
+ const { agentClient } = require('./agent_api');
19
+
20
+ function projectOf(args) {
21
+ const p = (typeof args.project === 'string' && args.project) || process.env.MOTIONODE_PROJECT || '';
22
+ if (!p) throw new Error('Say which project: --project <uuid or name> (or set MOTIONODE_PROJECT).');
23
+ return encodeURIComponent(p);
24
+ }
25
+
26
+ function readText(file) {
27
+ if (typeof file !== 'string' || !file) throw new Error('Give the content with --file <path>.');
28
+ return fs.readFileSync(file, 'utf8');
29
+ }
30
+
31
+ const list = (value) => (typeof value === 'string' ? value.split('|').map((s) => s.trim()).filter(Boolean) : []);
32
+
33
+ async function deliverables(config, args, { api = agentClient(config), out = console.log } = {}) {
34
+ const res = await api.get(`/project/${projectOf(args)}/deliverables`);
35
+ for (const d of res.deliverables || []) out(`${d.displayId || '-'}\t${d.title}${Number(d.isInterrupt) ? ' (Interrupt)' : ''}\t${d.uuid}`);
36
+ return res;
37
+ }
38
+
39
+ async function createWorkItem(config, args, { api = agentClient(config), out = console.log } = {}) {
40
+ if (typeof args.title !== 'string' || !args.title.trim()) throw new Error('Give the work item a --title.');
41
+ const description = typeof args['description-file'] === 'string' ? readText(args['description-file']) : (typeof args.description === 'string' ? args.description : '');
42
+ try {
43
+ const res = await api.post(`/project/${projectOf(args)}/work_items`, {
44
+ title: args.title,
45
+ description,
46
+ deliverable: typeof args.deliverable === 'string' ? args.deliverable : null,
47
+ newDeliverable: typeof args['new-deliverable'] === 'string' ? args['new-deliverable'] : null,
48
+ acceptanceCriteria: list(args.criteria),
49
+ confirmedNew: args['confirmed-new'] === true,
50
+ });
51
+ out(`✓ ${res.workItem.displayId || res.workItem.uuid} "${res.workItem.title}" in ${res.deliverable.displayId || ''} ${res.deliverable.title}${res.deliverable.created ? ' (new deliverable)' : ''} — in setup, nothing started`);
52
+ for (const s of res.similar || []) out(` similar open work: ${s.displayId || s.uuid} "${s.title}"${s.why ? ` (${s.why})` : ''}`);
53
+ return res;
54
+ } catch (err) {
55
+ // 409: open work already has this title -- the API names it.
56
+ if (err.status === 409) out(' (pass --confirmed-new if this is genuinely separate)');
57
+ throw err;
58
+ }
59
+ }
60
+
61
+ /** C1-032: change a work item's title and/or description, as the connected person (their edit rights on the project). */
62
+ async function updateWorkItem(config, args, { api = agentClient(config), out = console.log } = {}) {
63
+ const ref = args._[1];
64
+ if (!ref) throw new Error('Which work item? motionode work-item update <code> --title T');
65
+ const patch = {};
66
+ if (typeof args.title === 'string') patch.title = args.title;
67
+ if (typeof args['description-file'] === 'string') patch.description = readText(args['description-file']);
68
+ else if (typeof args.description === 'string') patch.description = args.description;
69
+ if (typeof args.status === 'string') patch.status = args.status;
70
+ if (!Object.keys(patch).length) throw new Error('Say what to change: --title and/or --description (or --description-file).');
71
+ const res = await api.patch(`/project/${projectOf(args)}/work_items/${encodeURIComponent(ref)}`, patch);
72
+ out(res.changed && res.changed.length
73
+ ? `✓ ${res.workItem.displayId || res.workItem.uuid} "${res.workItem.title}": changed ${res.changed.join(' and ')}`
74
+ : `= ${res.workItem.displayId || res.workItem.uuid} "${res.workItem.title}": nothing to change`);
75
+ return res;
76
+ }
77
+
78
+ async function notes(config, args, { api = agentClient(config), out = console.log } = {}) {
79
+ const sub = args._[0] || 'list';
80
+ const project = projectOf(args);
81
+ if (sub === 'list') {
82
+ const res = await api.get(`/project/${project}/notes`);
83
+ for (const n of res.notes || []) out(`${n.uuid}\t${n.title || '(untitled)'}\t${n.entityType}${n.category ? `/${n.category}` : ''}\t${n.chars} chars`);
84
+ return res;
85
+ }
86
+ const uuid = args._[1];
87
+ if (sub === 'get') {
88
+ if (!uuid) throw new Error('Which note? motionode notes get <uuid>');
89
+ const res = await api.get(`/project/${project}/notes/${encodeURIComponent(uuid)}`);
90
+ out(args.html ? (res.note.html || '') : (res.note.text || ''));
91
+ return res;
92
+ }
93
+ if (sub === 'create') {
94
+ const res = await api.post(`/project/${project}/notes`, {
95
+ markdown: readText(args.file),
96
+ category: typeof args.category === 'string' ? args.category : null,
97
+ deliverable: typeof args.deliverable === 'string' ? args.deliverable : null,
98
+ ticket: typeof args.ticket === 'string' ? args.ticket : null,
99
+ shareWith: typeof args.share === 'string' ? args.share.split(',').map((s) => s.trim()).filter(Boolean) : [],
100
+ });
101
+ out(`✓ note ${res.note.uuid} "${res.note.title || ''}" (${res.note.entityType})`);
102
+ return res;
103
+ }
104
+ if (sub === 'update') {
105
+ if (!uuid) throw new Error('Which note? motionode notes update <uuid> --file F.md');
106
+ const res = await api.patch(`/project/${project}/notes/${encodeURIComponent(uuid)}`, {
107
+ markdown: readText(args.file),
108
+ category: typeof args.category === 'string' ? args.category : null,
109
+ });
110
+ out(`✓ updated note ${res.note.uuid} "${res.note.title || ''}"`);
111
+ return res;
112
+ }
113
+ throw new Error(`Unknown: motionode notes ${sub} (list | get | create | update)`);
114
+ }
115
+
116
+ module.exports = { deliverables, createWorkItem, updateWorkItem, notes, _internals: { projectOf, list } };
@@ -0,0 +1,58 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * `motionode signals hook <event>`: run by Claude Code hooks (no model, no network;
5
+ * reads the local inbox that `motionode signals watch` keeps). It puts pending
6
+ * Motionode signals in front of the agent at a safe point:
7
+ * PostToolUse / UserPromptSubmit → add them to the agent's context;
8
+ * Stop → refuse to finish while a signal that needs action is unacknowledged
9
+ * (never twice in a row: Claude Code's stop_hook_active).
10
+ * The agent clears a signal with the MCP tool ack_signal.
11
+ */
12
+
13
+ const Inbox = require('./inbox');
14
+
15
+ function readStdin() {
16
+ return new Promise((resolve) => {
17
+ if (process.stdin.isTTY) return resolve({});
18
+ let data = '';
19
+ process.stdin.setEncoding('utf8');
20
+ process.stdin.on('data', (c) => { data += c; });
21
+ process.stdin.on('end', () => { try { resolve(JSON.parse(data || '{}')); } catch (_e) { resolve({}); } });
22
+ setTimeout(() => resolve({}), 1500);
23
+ });
24
+ }
25
+
26
+ function describe(signals) {
27
+ const lines = signals.slice(0, 8).map((s) => `- [#${s.id} · ${s.kind}${s.requiresAction ? ' · needs action' : ''} · ticket ${s.ticketRef || '?'}] ${String(s.summary || '').replace(/\s+/g, ' ').slice(0, 300)}`);
28
+ const more = signals.length > 8 ? `\n…and ${signals.length - 8} more (call get_signals).` : '';
29
+ return `Motionode signals for this work (acknowledge each with the motionode ack_signal tool; set incorporated=true only once your work reflects it):\n${lines.join('\n')}${more}`;
30
+ }
31
+
32
+ async function run(event) {
33
+ const input = await readStdin();
34
+ const dir = input.cwd || process.cwd();
35
+ const pending = Inbox.read(dir);
36
+ if (event === 'Stop') {
37
+ if (input.stop_hook_active) return; // already blocked once; let the agent stop
38
+ const critical = pending.filter((s) => s.requiresAction);
39
+ if (!critical.length) return;
40
+ process.stdout.write(JSON.stringify({
41
+ decision: 'block',
42
+ reason: `${describe(critical)}\nBefore finishing: handle these, or acknowledge them with ack_signal and say why they don't apply.`,
43
+ }));
44
+ return;
45
+ }
46
+ if (!pending.length) return;
47
+ process.stdout.write(JSON.stringify({
48
+ hookSpecificOutput: { hookEventName: event, additionalContext: describe(pending) },
49
+ }));
50
+ }
51
+
52
+ function printPending() {
53
+ const pending = Inbox.read(process.cwd());
54
+ if (!pending.length) { console.log('No Motionode signals waiting for this repository.'); return; }
55
+ console.log(describe(pending));
56
+ }
57
+
58
+ module.exports = { run, printPending, describe };