cawdev-cli 0.9.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/mcp/server.mjs ADDED
@@ -0,0 +1,2163 @@
1
+ #!/usr/bin/env node
2
+ // cawdev's MCP server: a coding agent's view of a project's roadmap and
3
+ // changelog. Plain Node, zero dependencies, stdio JSON-RPC.
4
+ //
5
+ // Drop it into any repository's .mcp.json — see README.md.
6
+ //
7
+ // It is deliberately a *translation*. The API's verbs were built to mirror
8
+ // these tools one-for-one (R4, R5), so there is no second implementation here
9
+ // with its own opinions about what a status means. If a rule seems to be
10
+ // missing from this file, it is because the server enforces it and the refusal
11
+ // is passed through verbatim — the API's message is the one that explains the
12
+ // rule.
13
+
14
+ import { readFile } from 'node:fs/promises';
15
+ import { dirname, join, resolve } from 'node:path';
16
+ import { createInterface } from 'node:readline';
17
+ import {
18
+ coveredBy,
19
+ exactRuleFor,
20
+ suggestionFor,
21
+ summaryOf,
22
+ withinCeiling,
23
+ } from '../lib/tool-rules.mjs';
24
+ import { findDestructive, withinScope } from '../lib/secrets.mjs';
25
+
26
+ const NAME = 'cawdev';
27
+ const VERSION = '0.1.0';
28
+
29
+ /** MCP revision we implement. A client asking for another is echoed its own. */
30
+ const PROTOCOL_VERSION = '2024-11-05';
31
+
32
+ // --- configuration ----------------------------------------------------------
33
+
34
+ /**
35
+ * Read on EVERY call, never cached.
36
+ *
37
+ * dycrypt learned this the hard way and the lesson is worth keeping: an agent
38
+ * that has been writing to the wrong platform for an hour, because someone
39
+ * edited .env and the server was still holding the old value, is a bad
40
+ * afternoon. Re-reading a small file per call costs nothing next to an HTTP
41
+ * round trip.
42
+ */
43
+ async function readConfig() {
44
+ const fromFile = await readDotEnv(process.cwd());
45
+ const url = process.env.CAWDEV_URL ?? fromFile.values.CAWDEV_URL;
46
+ const token = process.env.CAWDEV_TOKEN ?? fromFile.values.CAWDEV_TOKEN;
47
+ const project = process.env.CAWDEV_PROJECT ?? fromFile.values.CAWDEV_PROJECT;
48
+
49
+ return {
50
+ url: (url ?? 'http://localhost:4200').replace(/\/+$/, ''),
51
+ token,
52
+ project,
53
+ // Where each value came from. roadmap_where reports this, because "which
54
+ // platform am I actually talking to" is the first question when a call goes
55
+ // somewhere unexpected.
56
+ from: {
57
+ url: process.env.CAWDEV_URL ? 'environment' : fromFile.values.CAWDEV_URL ? fromFile.path : 'default',
58
+ token: process.env.CAWDEV_TOKEN ? 'environment' : fromFile.values.CAWDEV_TOKEN ? fromFile.path : 'unset',
59
+ project: process.env.CAWDEV_PROJECT
60
+ ? 'environment'
61
+ : fromFile.values.CAWDEV_PROJECT
62
+ ? fromFile.path
63
+ : 'unset',
64
+ },
65
+ };
66
+ }
67
+
68
+ async function readDotEnv(startDirectory) {
69
+ let directory = resolve(startDirectory);
70
+ for (let depth = 0; depth < 6; depth++) {
71
+ const candidate = join(directory, '.env');
72
+ try {
73
+ const text = await readFile(candidate, 'utf8');
74
+ const values = {};
75
+ for (const line of text.split('\n')) {
76
+ const match = /^\s*([A-Z0-9_]+)\s*=\s*(.*?)\s*$/.exec(line);
77
+ if (match) values[match[1]] = match[2].replace(/^["']|["']$/g, '');
78
+ }
79
+ return { values, path: candidate };
80
+ } catch {
81
+ const parent = dirname(directory);
82
+ if (parent === directory) break;
83
+ directory = parent;
84
+ }
85
+ }
86
+ return { values: {}, path: null };
87
+ }
88
+
89
+ // --- talking to cawdev ------------------------------------------------------
90
+
91
+ class CawdevError extends Error {}
92
+
93
+ async function api(config, path, { method = 'GET', body } = {}) {
94
+ if (!config.token) {
95
+ throw new CawdevError(
96
+ 'No CAWDEV_TOKEN. Mint one in the cawdev console under Agent tokens, then set it in ' +
97
+ 'the environment or a .env file beside this repository. Run roadmap_where to see ' +
98
+ 'where this server is looking.',
99
+ );
100
+ }
101
+
102
+ let response;
103
+ try {
104
+ response = await fetch(`${config.url}${path}`, {
105
+ method,
106
+ headers: {
107
+ authorization: `Bearer ${config.token}`,
108
+ ...(body ? { 'content-type': 'application/json' } : {}),
109
+ },
110
+ body: body ? JSON.stringify(body) : undefined,
111
+ });
112
+ } catch (failure) {
113
+ throw new CawdevError(
114
+ `Could not reach cawdev at ${config.url} (${failure.message}). ` +
115
+ 'Is it running? roadmap_where shows which URL this server is using and where it read it.',
116
+ );
117
+ }
118
+
119
+ const text = await response.text();
120
+ const parsed = text ? safeJson(text) : null;
121
+
122
+ if (!response.ok) {
123
+ // The API's own message explains the rule that was hit — a missing scope, a
124
+ // status that needs a branch. Passing it through verbatim is the point of
125
+ // this server being a translation.
126
+ throw new CawdevError(parsed?.message ?? `${method} ${path} failed: HTTP ${response.status}`);
127
+ }
128
+ return parsed;
129
+ }
130
+
131
+ function safeJson(text) {
132
+ try {
133
+ return JSON.parse(text);
134
+ } catch {
135
+ return null;
136
+ }
137
+ }
138
+
139
+ /**
140
+ * Which project a call is about.
141
+ *
142
+ * A token granted exactly one project needs no argument — that is the common
143
+ * case and asking for it every time is noise. A multi-project token must say,
144
+ * and being told *which* projects it can see is more useful than being told it
145
+ * guessed wrong.
146
+ */
147
+ async function resolveProject(config, requested) {
148
+ const identity = await api(config, '/api/agent/whoami');
149
+ const reachable = identity.projects.map((project) => project.slug);
150
+
151
+ if (requested) {
152
+ if (!reachable.includes(requested)) {
153
+ throw new CawdevError(
154
+ `This token cannot reach "${requested}". It can reach: ${
155
+ reachable.length ? reachable.join(', ') : '(nothing — check its grants in the console)'
156
+ }`,
157
+ );
158
+ }
159
+ return requested;
160
+ }
161
+
162
+ if (config.project) {
163
+ if (!reachable.includes(config.project)) {
164
+ throw new CawdevError(
165
+ `CAWDEV_PROJECT is "${config.project}" (from ${config.from.project}), but this token ` +
166
+ `cannot reach it. It can reach: ${reachable.join(', ') || '(nothing)'}`,
167
+ );
168
+ }
169
+ return config.project;
170
+ }
171
+
172
+ if (reachable.length === 1) {
173
+ return reachable[0];
174
+ }
175
+ if (reachable.length === 0) {
176
+ throw new CawdevError(
177
+ 'This token has no projects. Check its grants in the cawdev console under Agent tokens.',
178
+ );
179
+ }
180
+ throw new CawdevError(
181
+ `This token can reach several projects, so say which: ${reachable.join(', ')}. ` +
182
+ 'Pass `project`, or set CAWDEV_PROJECT.',
183
+ );
184
+ }
185
+
186
+ /**
187
+ * The run this token belongs to, for the orchestration tools.
188
+ *
189
+ * A plain `cawd_` token gets a refusal that says what it is missing rather than
190
+ * a confusing 404: these tools only mean anything inside a run, and an agent
191
+ * holding the wrong token should be told so plainly.
192
+ */
193
+ async function requireRun(config) {
194
+ const identity = await api(config, '/api/agent/whoami');
195
+ if (!identity.runId) {
196
+ throw new CawdevError(
197
+ 'This tool needs a run. The token in use is a plain cawd_ token, which can read and write ' +
198
+ 'the roadmap and changelog but is not attached to any run. A run token (cawdr_) is ' +
199
+ 'minted by the runner when a run starts and handed to the session it spawns.',
200
+ );
201
+ }
202
+ const project = identity.projects[0]?.slug;
203
+ if (!project) {
204
+ throw new CawdevError('This run token has no project. Its run may have ended.');
205
+ }
206
+ return { runId: identity.runId, project };
207
+ }
208
+
209
+ /** How long ask_user waits before handing back a pending question. */
210
+ function askTimeoutSeconds() {
211
+ // Overridable so a test does not have to wait ten minutes to exercise the
212
+ // pending path.
213
+ const configured = Number(process.env.CAWDEV_ASK_TIMEOUT_SECONDS);
214
+ return Number.isFinite(configured) && configured > 0 ? configured : 600;
215
+ }
216
+
217
+ /**
218
+ * Waits for an answer, re-polling quietly.
219
+ *
220
+ * The platform's long poll returns after at most 25 seconds, so a genuine wait
221
+ * is many polls. Doing that here rather than in the agent means the agent
222
+ * experiences one natural blocking ask, while the person experiences an inbox
223
+ * item — which is the whole shape R10 is after.
224
+ */
225
+ async function pollForAnswer(config, project, runId, questionId, seconds) {
226
+ const deadline = Date.now() + seconds * 1000;
227
+ while (Date.now() < deadline) {
228
+ const remaining = Math.ceil((deadline - Date.now()) / 1000);
229
+ const response = await fetch(
230
+ `${config.url}/api/projects/${project}/runs/${runId}/questions/${questionId}/answer` +
231
+ `?wait=${Math.min(25, Math.max(1, remaining))}`,
232
+ { headers: { authorization: `Bearer ${config.token}` } },
233
+ );
234
+ if (response.status === 200) {
235
+ return await response.json();
236
+ }
237
+ if (response.status !== 204) {
238
+ const text = await response.text();
239
+ throw new CawdevError(safeJson(text)?.message ?? `waiting failed: HTTP ${response.status}`);
240
+ }
241
+ // 204 means "not yet" — ask again.
242
+ }
243
+ return null;
244
+ }
245
+
246
+ /**
247
+ * Waits for a whole round, re-polling quietly — R96.
248
+ *
249
+ * The same shape as {@link pollForAnswer} and deliberately not a generalisation
250
+ * of it: the platform returns 204 until EVERY question in the round is
251
+ * answered, so what "not yet" means differs, and one function taking a flag
252
+ * would hide exactly that.
253
+ */
254
+ async function pollForGroup(config, project, runId, groupId, seconds) {
255
+ const deadline = Date.now() + seconds * 1000;
256
+ while (Date.now() < deadline) {
257
+ const remaining = Math.ceil((deadline - Date.now()) / 1000);
258
+ const response = await fetch(
259
+ `${config.url}/api/projects/${project}/runs/${runId}/question-groups/${groupId}/answers` +
260
+ `?wait=${Math.min(25, Math.max(1, remaining))}`,
261
+ { headers: { authorization: `Bearer ${config.token}` } },
262
+ );
263
+ if (response.status === 200) {
264
+ return await response.json();
265
+ }
266
+ if (response.status !== 204) {
267
+ const text = await response.text();
268
+ throw new CawdevError(safeJson(text)?.message ?? `waiting failed: HTTP ${response.status}`);
269
+ }
270
+ // 204 means "some of it is still unanswered" — ask again.
271
+ }
272
+ return null;
273
+ }
274
+
275
+ /** Where an interview stands, in the words the agent needs — R101. */
276
+ function renderRounds(where) {
277
+ const left = Math.max(0, where.roundsAllowed - where.roundsAsked);
278
+ if (where.finishNow) {
279
+ return 'They have said to finish. Write the brief from what has been answered.';
280
+ }
281
+ return (
282
+ `Round ${where.roundsAsked} of ${where.roundsAllowed} asked, ${where.roundsAnswered} ` +
283
+ `answered. You may ask ${left} more (the ceiling is ${where.mostRounds}). ` +
284
+ (left === 0
285
+ ? 'Call await_more_rounds: the person is being asked whether they have time for more.'
286
+ : '')
287
+ ).trim();
288
+ }
289
+
290
+ /**
291
+ * A finished round, as the session reads it back.
292
+ *
293
+ * Question and answer together, in the order they were asked. A list of answers
294
+ * alone would be positional, and an agent matching six answers to six questions
295
+ * by counting is an agent one skipped question away from acting on the wrong
296
+ * one.
297
+ */
298
+ function renderRound(asked, answered) {
299
+ const title = asked?.title ?? 'The round';
300
+ const lines = answered.map((question, at) => {
301
+ const said = question.answeredByEmail
302
+ ? `${question.answeredByEmail}: ${question.answer}`
303
+ : question.answer;
304
+ const opinions = (question.opinions ?? [])
305
+ .map((opinion) => ` ${opinion.authorEmail}: ${opinion.body}`)
306
+ .join('\n');
307
+ const body = `${at + 1}. ${question.question}\n ${said}`;
308
+ return opinions ? `${body}\n what people said first:\n${opinions}` : body;
309
+ });
310
+ return `${title} — every question answered:\n\n${lines.join('\n\n')}`;
311
+ }
312
+
313
+ /**
314
+ * The answer, with the argument that produced it.
315
+ *
316
+ * A question can be passed round before somebody answers it (R36), and the
317
+ * opinions collected on the way are on the question. Showing them matters: the
318
+ * answer is often "do the second one", and the reasoning that settled it lives
319
+ * in the thread rather than in the sentence you were handed.
320
+ */
321
+ function renderAnswer(answered) {
322
+ const said = (answered.opinions ?? [])
323
+ .map((opinion) => ` ${opinion.authorEmail}: ${opinion.body}`)
324
+ .join('\n');
325
+
326
+ const answer = `${answered.answeredByEmail} answered:\n\n${answered.answer}`;
327
+ return said ? `${answer}\n\nWhat people said before deciding:\n${said}` : answer;
328
+ }
329
+
330
+ // --- the tools --------------------------------------------------------------
331
+
332
+ const PROJECT_ARGUMENT = {
333
+ project: {
334
+ type: 'string',
335
+ description:
336
+ 'Project slug. Optional when the token grants exactly one project, or CAWDEV_PROJECT is set.',
337
+ },
338
+ };
339
+
340
+ const TOOLS = [
341
+ {
342
+ name: 'roadmap_where',
343
+ description:
344
+ 'Which cawdev this is talking to, which token it is using, where each was read from, ' +
345
+ 'and who the platform says you are. Run this first when a call goes somewhere unexpected.',
346
+ inputSchema: { type: 'object', properties: {} },
347
+ handler: async (config) => {
348
+ const lines = [
349
+ `platform: ${config.url} (from ${config.from.url})`,
350
+ `token: ${config.token ? `${config.token.slice(0, 11)}…` : '(unset)'} (from ${config.from.token})`,
351
+ `project: ${config.project ?? '(unset)'} (from ${config.from.project})`,
352
+ `cwd: ${process.cwd()}`,
353
+ '',
354
+ ];
355
+ try {
356
+ const identity = await api(config, '/api/agent/whoami');
357
+ lines.push(
358
+ `you are: ${identity.kind} "${identity.label}" owned by ${identity.ownerEmail}`,
359
+ identity.tokenRevoked ? 'WARNING: this token is revoked.' : '',
360
+ '',
361
+ identity.projects.length ? 'reachable projects:' : 'reachable projects: (none)',
362
+ );
363
+ for (const project of identity.projects) {
364
+ lines.push(
365
+ ` ${project.slug}${project.archived ? ' (archived)' : ''} — ${project.name}` +
366
+ `\n scopes: ${project.scopes.join(', ') || '(none usable)'}` +
367
+ `\n owner's role there: ${project.ownerRole}`,
368
+ );
369
+ }
370
+ } catch (failure) {
371
+ lines.push(`the platform refused: ${failure.message}`);
372
+ }
373
+ return lines.filter((line) => line !== '').join('\n');
374
+ },
375
+ },
376
+
377
+ {
378
+ name: 'roadmap_statuses',
379
+ description:
380
+ 'The roadmap statuses, what each means, and what each one requires — IN DEVELOPMENT a ' +
381
+ 'branch, MERGED the merge, SHIPPED a version, DECLINED a reason. Read this rather than ' +
382
+ 'guessing: the set differs by KIND, and an issue calls two of them different words.',
383
+ inputSchema: { type: 'object', properties: {} },
384
+ handler: async (config) => {
385
+ const statuses = await api(config, '/api/roadmap/statuses');
386
+ return statuses
387
+ .map(
388
+ (status) =>
389
+ `${status.display}${status.requires ? ` (requires a ${status.requires})` : ''}\n ${status.meaning}`,
390
+ )
391
+ .join('\n');
392
+ },
393
+ },
394
+
395
+ {
396
+ name: 'roadmap_list',
397
+ description:
398
+ 'A project\'s roadmap entries, optionally filtered by status or by sprint. Use brief=true ' +
399
+ 'to survey without pulling every entry\'s body into context.',
400
+ inputSchema: {
401
+ type: 'object',
402
+ properties: {
403
+ ...PROJECT_ARGUMENT,
404
+ sprint: {
405
+ type: 'integer',
406
+ minimum: 1,
407
+ description:
408
+ 'Only cards in this sprint, by number — 1 for S1. A number the project has no ' +
409
+ 'sprint for is refused naming the ones it has.',
410
+ },
411
+ status: {
412
+ type: 'string',
413
+ enum: [
414
+ 'CONSIDERING',
415
+ 'PLANNED',
416
+ 'NEW',
417
+ 'CONFIRMED',
418
+ 'IN_DEVELOPMENT',
419
+ 'MERGED',
420
+ 'SHIPPED',
421
+ 'DECLINED',
422
+ ],
423
+ },
424
+ brief: { type: 'boolean', description: 'Omit bodies. Default true.' },
425
+ },
426
+ },
427
+ handler: async (config, args) => {
428
+ const slug = await resolveProject(config, args.project);
429
+ const brief = args.brief !== false;
430
+ const query = new URLSearchParams();
431
+ if (args.status) query.set('status', args.status);
432
+ // R257. The API refuses a number with no sprint behind it, naming the
433
+ // sprints there are; that sentence is passed through as the tool error.
434
+ if (args.sprint) query.set('sprint', String(args.sprint));
435
+ query.set('brief', String(brief));
436
+
437
+ const entries = await api(config, `/api/projects/${slug}/roadmap?${query}`);
438
+ if (!entries.length) {
439
+ return `${slug} has no matching entries.`;
440
+ }
441
+ return entries.map((entry) => formatEntry(entry, { brief })).join('\n\n');
442
+ },
443
+ },
444
+
445
+ {
446
+ name: 'roadmap_get',
447
+ description:
448
+ 'One roadmap entry in full: its body, THE PLAN somebody agreed for it, and the ' +
449
+ 'discussion on it. Read the plan before doing any of the work — it is what was decided, ' +
450
+ 'and it names the files. Read the comments before proposing anything: they are where an ' +
451
+ 'objection was answered, and re-proposing what was talked out three months ago is the ' +
452
+ 'thing they exist to stop.',
453
+ inputSchema: {
454
+ type: 'object',
455
+ properties: { ...PROJECT_ARGUMENT, number: {
456
+ type: ['string', 'integer'],
457
+ description: 'The card by ref — R91, i91 — or a bare number for a roadmap card (R221).',
458
+ } },
459
+ required: ['number'],
460
+ },
461
+ handler: async (config, args) => {
462
+ const slug = await resolveProject(config, args.project);
463
+ const entry = await api(config, `/api/projects/${slug}/roadmap/${args.number}`);
464
+ const comments = await api(
465
+ config,
466
+ `/api/projects/${slug}/roadmap/${args.number}/comments`,
467
+ );
468
+ // R124. The plan of record is part of what this card SAYS — the body is
469
+ // what we want, the plan is how we mean to get it — so it comes back with
470
+ // the entry rather than from a tool of its own.
471
+ //
472
+ // Tolerated missing on purpose. This server talks to whatever platform it
473
+ // was pointed at, and one that has not run V59 has no such endpoint; a
474
+ // 404 here must cost the plan, not the card.
475
+ const plans = await api(config, `/api/projects/${slug}/roadmap/${args.number}/plans`)
476
+ .catch(() => []);
477
+ return formatEntry(entry, { comments, plan: plans?.[0] ?? null });
478
+ },
479
+ },
480
+
481
+ {
482
+ name: 'code_map',
483
+ description:
484
+ "The shape of this project's code: every directory, how many files it holds, and which " +
485
+ 'directories depend on which. READ THIS BEFORE GREPPING AROUND A REPOSITORY YOU DO NOT ' +
486
+ 'KNOW. It is one call, it is already computed, and it answers "where does this live" and ' +
487
+ '"what would I break" without opening a single file — the same questions a dozen searches ' +
488
+ 'answer more slowly and less completely.',
489
+ inputSchema: {
490
+ type: 'object',
491
+ properties: {
492
+ ...PROJECT_ARGUMENT,
493
+ under: {
494
+ type: 'string',
495
+ description:
496
+ 'Only this directory and below, e.g. "backend/src/main/java". Omit for the whole ' +
497
+ 'project, which is the right first call.',
498
+ },
499
+ },
500
+ },
501
+ handler: async (config, args) => {
502
+ const slug = await resolveProject(config, args.project);
503
+ const map = await codeMapOrNothing(config, slug);
504
+ if (!map) {
505
+ return `No machine has mapped ${slug} yet, so there is nothing to read here. ` +
506
+ 'Work as you would have anyway.';
507
+ }
508
+ return formatCodeMap(map, args.under);
509
+ },
510
+ },
511
+
512
+ {
513
+ name: 'file_deps',
514
+ description:
515
+ 'What one file imports, and what imports it. Use it before changing a file: the second ' +
516
+ 'half is the blast radius, and it is the half that grepping for a filename does not give ' +
517
+ 'you reliably.',
518
+ inputSchema: {
519
+ type: 'object',
520
+ properties: {
521
+ ...PROJECT_ARGUMENT,
522
+ path: {
523
+ type: 'string',
524
+ description: 'Repository-relative, e.g. "tools/runner/runner.mjs".',
525
+ },
526
+ },
527
+ required: ['path'],
528
+ },
529
+ handler: async (config, args) => {
530
+ const slug = await resolveProject(config, args.project);
531
+ const map = await codeMapOrNothing(config, slug);
532
+ if (!map) {
533
+ return `No machine has mapped ${slug} yet, so there is nothing to read here.`;
534
+ }
535
+ return formatFileDeps(map, args.path);
536
+ },
537
+ },
538
+
539
+ {
540
+ name: 'roadmap_comment',
541
+ description:
542
+ 'Say something about an entry, beside the entry rather than inside it. Use it for the ' +
543
+ 'argument: an objection, a measurement, why an obvious approach was not taken. The body ' +
544
+ 'is where a settled conclusion is written down — edit that when the discussion reaches ' +
545
+ 'one. Comments cannot be deleted, by you or by anyone.',
546
+ inputSchema: {
547
+ type: 'object',
548
+ properties: {
549
+ ...PROJECT_ARGUMENT,
550
+ number: {
551
+ type: ['string', 'integer'],
552
+ description: 'The card by ref — R91, i91 — or a bare number for a roadmap card (R221).',
553
+ },
554
+ body: { type: 'string', description: 'Markdown.' },
555
+ },
556
+ required: ['number', 'body'],
557
+ },
558
+ handler: async (config, args) => {
559
+ const slug = await resolveProject(config, args.project);
560
+ await api(config, `/api/projects/${slug}/roadmap/${args.number}/comments`, {
561
+ method: 'POST',
562
+ body: { body: args.body },
563
+ });
564
+ // As asked for: this call is given a ref and never reads the card back,
565
+ // and a bare number was a roadmap card — R221.
566
+ return `Commented on card ${args.number} in ${slug}. It cannot be deleted — that is the point.`;
567
+ },
568
+ },
569
+
570
+ {
571
+ name: 'roadmap_create',
572
+ description:
573
+ 'Create a roadmap entry. The platform allocates its permanent number — written R91 on ' +
574
+ 'a roadmap card and i91 on an issue, one sequence across both — and the ' +
575
+ 'platform decides where a card starts — CONSIDERING — unless you say otherwise; ' +
576
+ 'a status that requires something must be given it.',
577
+ inputSchema: {
578
+ type: 'object',
579
+ properties: {
580
+ ...PROJECT_ARGUMENT,
581
+ title: { type: 'string' },
582
+ body: { type: 'string', description: 'Markdown.' },
583
+ // NEW and CONFIRMED are an issue's statuses and this creates a ROADMAP
584
+ // card, so the API refuses both: offering an agent a value that is
585
+ // always a 400 is the tool lying about itself.
586
+ status: {
587
+ type: 'string',
588
+ enum: [
589
+ 'CONSIDERING',
590
+ 'PLANNED',
591
+ 'IN_DEVELOPMENT',
592
+ 'MERGED',
593
+ 'SHIPPED',
594
+ 'DECLINED',
595
+ ],
596
+ },
597
+ branch: { type: 'string' },
598
+ merge: { type: 'string', description: 'What MERGED needs: the PR, the merge commit, or the sha.' },
599
+ version: { type: 'string' },
600
+ reason: { type: 'string' },
601
+ section: { type: 'string', description: 'Which part of the roadmap, e.g. "Phase 2 — …".' },
602
+ sprint: {
603
+ type: 'integer',
604
+ minimum: 0,
605
+ description:
606
+ 'The sprint to file this card into, by number — 1 for S1. 0 takes it out of its ' +
607
+ 'sprint; leave it out to leave it alone. A CLOSED sprint refuses a card that is ' +
608
+ 'not already in it, and only a person can reopen one — do not retry.',
609
+ },
610
+ related: {
611
+ type: 'array',
612
+ items: { type: ['string', 'integer'] },
613
+ description: 'Refs — R12, i15; a bare number is a roadmap card (R221).',
614
+ },
615
+ after: {
616
+ type: 'array',
617
+ items: { type: ['string', 'integer'] },
618
+ description:
619
+ 'Cards this one starts coding after — it waits in the queue until every one of ' +
620
+ 'them is MERGED or SHIPPED. Refs, as `related`. Same project, must exist, never itself.',
621
+ },
622
+ },
623
+ required: ['title'],
624
+ },
625
+ handler: async (config, args) => {
626
+ const slug = await resolveProject(config, args.project);
627
+ const entry = await api(config, `/api/projects/${slug}/roadmap`, {
628
+ method: 'POST',
629
+ body: pick(args, [
630
+ 'title', 'body', 'status', 'branch', 'merge', 'version', 'reason', 'section', 'related',
631
+ 'after', 'sprint',
632
+ ]),
633
+ });
634
+ return `Created ${refOf(entry)} in ${slug}.\n\n${formatEntry(entry, {})}`;
635
+ },
636
+ },
637
+
638
+ {
639
+ name: 'issue_list',
640
+ description:
641
+ 'What is broken in a project, and how badly — R85. The same numbering as the roadmap, ' +
642
+ 'written with an i: card 91 filed as an issue is i91, and there is no R91 as well. ' +
643
+ 'Statuses are NEW, CONFIRMED, IN_DEVELOPMENT, MERGED (drawn as ' +
644
+ 'Resolved) and DECLINED (Won\'t fix).',
645
+ inputSchema: {
646
+ type: 'object',
647
+ properties: {
648
+ ...PROJECT_ARGUMENT,
649
+ status: {
650
+ type: 'string',
651
+ enum: ['NEW', 'CONFIRMED', 'IN_DEVELOPMENT', 'MERGED', 'SHIPPED', 'DECLINED'],
652
+ },
653
+ brief: { type: 'boolean', description: 'Drop the bodies.' },
654
+ },
655
+ },
656
+ handler: async (config, args) => {
657
+ const slug = await resolveProject(config, args.project);
658
+ const query = new URLSearchParams();
659
+ if (args.status) query.set('status', args.status);
660
+ if (args.brief) query.set('brief', 'true');
661
+ const suffix = query.toString() ? `?${query}` : '';
662
+ const issues = await api(config, `/api/projects/${slug}/issues${suffix}`);
663
+ if (!issues.length) {
664
+ return `${slug} has no issues${args.status ? ` in ${args.status}` : ''}.`;
665
+ }
666
+ return issues.map((issue) => formatEntry(issue, {})).join('\n\n---\n\n');
667
+ },
668
+ },
669
+
670
+ {
671
+ name: 'issue_file',
672
+ description:
673
+ 'File an issue: something that is broken, with how badly. Lands at NEW — filed and not ' +
674
+ 'yet triaged — unless you say CONFIRMED, which claims you have already checked it. The ' +
675
+ 'severity is required: it is how the board is ordered. Use `related` to name the card it ' +
676
+ 'was found on, by ref (R84). Issues count on their own sequence — R221 — and are ' +
677
+ 'written with an i: i91 is the ninety-first issue, and R91 is another card.',
678
+ inputSchema: {
679
+ type: 'object',
680
+ properties: {
681
+ ...PROJECT_ARGUMENT,
682
+ title: { type: 'string' },
683
+ body: { type: 'string', description: 'Markdown. What happens, and what should instead.' },
684
+ severity: { type: 'string', enum: ['CRITICAL', 'MEDIUM', 'MINOR'] },
685
+ status: { type: 'string', enum: ['NEW', 'CONFIRMED'] },
686
+ related: {
687
+ type: 'array',
688
+ items: { type: ['string', 'integer'] },
689
+ description: 'Refs — R12, i15; a bare number is a roadmap card (R221).',
690
+ },
691
+ },
692
+ required: ['title', 'severity'],
693
+ },
694
+ handler: async (config, args) => {
695
+ const slug = await resolveProject(config, args.project);
696
+ const issue = await api(config, `/api/projects/${slug}/issues`, {
697
+ method: 'POST',
698
+ body: pick(args, ['title', 'body', 'severity', 'status', 'related']),
699
+ });
700
+ return `Filed ${refOf(issue)} in ${slug}.\n\n${formatEntry(issue, {})}`;
701
+ },
702
+ },
703
+
704
+ {
705
+ name: 'backlog_file',
706
+ description:
707
+ 'File feedback into the project\'s backlog — NOT a card. Something you noticed that a ' +
708
+ 'person should decide about: `kind` is your opinion, ISSUE (broken) or FEATURE (should ' +
709
+ 'also do / do better). A WRITER later accepts it as a roadmap card, accepts it as an ' +
710
+ 'issue, or refuses it with a reason. Use this when you are not sure it deserves a card; ' +
711
+ 'use roadmap_create or issue_file when you are.',
712
+ inputSchema: {
713
+ type: 'object',
714
+ properties: {
715
+ ...PROJECT_ARGUMENT,
716
+ kind: { type: 'string', enum: ['ISSUE', 'FEATURE'] },
717
+ title: { type: 'string' },
718
+ body: { type: 'string', description: 'Markdown. What you noticed, and what should happen instead.' },
719
+ },
720
+ required: ['kind', 'title', 'body'],
721
+ },
722
+ handler: async (config, args) => {
723
+ const slug = await resolveProject(config, args.project);
724
+ const item = await api(config, `/api/projects/${slug}/backlog`, {
725
+ method: 'POST',
726
+ body: pick(args, ['kind', 'title', 'body']),
727
+ });
728
+ return `Filed in ${slug}'s backlog as pending: "${item.title}". Somebody will accept it ` +
729
+ 'as a card or an issue, or refuse it.';
730
+ },
731
+ },
732
+
733
+ {
734
+ name: 'roadmap_update',
735
+ description:
736
+ 'Edit an entry\'s title, body, section, sprint, related refs or the cards it starts ' +
737
+ 'after. Use roadmap_set_status to move it.',
738
+ inputSchema: {
739
+ type: 'object',
740
+ properties: {
741
+ ...PROJECT_ARGUMENT,
742
+ number: {
743
+ type: ['string', 'integer'],
744
+ description: 'The card by ref — R91, i91 — or a bare number for a roadmap card (R221).',
745
+ },
746
+ title: { type: 'string' },
747
+ body: { type: 'string' },
748
+ section: { type: 'string' },
749
+ sprint: {
750
+ type: 'integer',
751
+ minimum: 0,
752
+ description:
753
+ 'The sprint to file this card into, by number — 1 for S1. 0 takes it out of its ' +
754
+ 'sprint; leave it out to leave it alone. A CLOSED sprint refuses a card that is ' +
755
+ 'not already in it, and only a person can reopen one — do not retry.',
756
+ },
757
+ related: {
758
+ type: 'array',
759
+ items: { type: ['string', 'integer'] },
760
+ description: 'Refs — R12, i15; a bare number is a roadmap card (R221). [] clears it.',
761
+ },
762
+ after: {
763
+ type: 'array',
764
+ items: { type: ['string', 'integer'] },
765
+ description:
766
+ 'Cards this one starts coding after — it waits in the queue until every one of ' +
767
+ 'them is MERGED or SHIPPED. Refs, as `related`. Same project, must exist, never ' +
768
+ 'itself. [] clears it.',
769
+ },
770
+ },
771
+ required: ['number'],
772
+ },
773
+ handler: async (config, args) => {
774
+ const slug = await resolveProject(config, args.project);
775
+ const entry = await api(config, `/api/projects/${slug}/roadmap/${args.number}`, {
776
+ method: 'PATCH',
777
+ // pick keeps a present 0, which is how a card leaves its sprint.
778
+ body: pick(args, ['title', 'body', 'section', 'related', 'after', 'sprint']),
779
+ });
780
+ return `Updated ${refOf(entry)}.\n\n${formatEntry(entry, {})}`;
781
+ },
782
+ },
783
+
784
+ {
785
+ name: 'roadmap_set_status',
786
+ description:
787
+ 'Move an entry to a status. Any status may move to any other — the rules are about what ' +
788
+ 'a status must carry, not a permitted path. IN_DEVELOPMENT needs a branch, MERGED the ' +
789
+ 'merge — the PR, the merge commit or the sha — and SHIPPED a version. Move a card to ' +
790
+ 'MERGED when its pull request lands; the branch may then be deleted. ' +
791
+ 'A card has ONE development status: how far the branch has got — written, reviewed, ' +
792
+ 'done — is on the WORK ITEM, not here, and there is nothing to set on the card between ' +
793
+ 'starting and merging.',
794
+ inputSchema: {
795
+ type: 'object',
796
+ properties: {
797
+ ...PROJECT_ARGUMENT,
798
+ number: {
799
+ type: ['string', 'integer'],
800
+ description: 'The card by ref — R91, i91 — or a bare number for a roadmap card (R221).',
801
+ },
802
+ status: {
803
+ type: 'string',
804
+ enum: [
805
+ 'CONSIDERING',
806
+ 'PLANNED',
807
+ 'NEW',
808
+ 'CONFIRMED',
809
+ 'IN_DEVELOPMENT',
810
+ 'MERGED',
811
+ 'SHIPPED',
812
+ 'DECLINED',
813
+ ],
814
+ },
815
+ branch: { type: 'string' },
816
+ merge: { type: 'string', description: 'What MERGED needs: the PR, the merge commit, or the sha.' },
817
+ version: { type: 'string' },
818
+ reason: { type: 'string' },
819
+ },
820
+ required: ['number', 'status'],
821
+ },
822
+ handler: async (config, args) => {
823
+ const slug = await resolveProject(config, args.project);
824
+ const entry = await api(config, `/api/projects/${slug}/roadmap/${args.number}/status`, {
825
+ method: 'POST',
826
+ body: pick(args, ['status', 'branch', 'merge', 'version', 'reason']),
827
+ });
828
+ return `${refOf(entry)} is now ${entry.statusDisplay}.\n\n${formatEntry(entry, {})}`;
829
+ },
830
+ },
831
+
832
+ {
833
+ name: 'roadmap_decline',
834
+ description:
835
+ 'Decline an entry, with a reason. This is the only exit an entry has — there is no delete. ' +
836
+ 'The reason is the point: it stops the idea being proposed again.',
837
+ inputSchema: {
838
+ type: 'object',
839
+ properties: { ...PROJECT_ARGUMENT, number: {
840
+ type: ['string', 'integer'],
841
+ description: 'The card by ref — R91, i91 — or a bare number for a roadmap card (R221).',
842
+ }, reason: { type: 'string' } },
843
+ required: ['number', 'reason'],
844
+ },
845
+ handler: async (config, args) => {
846
+ const slug = await resolveProject(config, args.project);
847
+ const entry = await api(config, `/api/projects/${slug}/roadmap/${args.number}/decline`, {
848
+ method: 'POST',
849
+ body: { reason: args.reason },
850
+ });
851
+ return `${refOf(entry)} declined.\n\n${formatEntry(entry, {})}`;
852
+ },
853
+ },
854
+
855
+ {
856
+ name: 'changelog_list',
857
+ description: 'A project\'s changelog, grouped by release, newest first.',
858
+ inputSchema: {
859
+ type: 'object',
860
+ properties: { ...PROJECT_ARGUMENT, version: { type: 'string', description: 'Only this release.' } },
861
+ },
862
+ handler: async (config, args) => {
863
+ const slug = await resolveProject(config, args.project);
864
+ const query = args.version ? `?version=${encodeURIComponent(args.version)}` : '';
865
+ const releases = await api(config, `/api/projects/${slug}/changelog${query}`);
866
+ if (!releases.length) return `${slug} has no changelog entries.`;
867
+
868
+ return releases
869
+ .map((release) => {
870
+ const head = `## ${release.version}${release.hasBreaking ? ' (contains breaking changes)' : ''}`;
871
+ const lines = release.entries.map(
872
+ (entry) =>
873
+ ` [${entry.number}] ${entry.category}${entry.breaking ? ' BREAKING' : ''}: ${entry.text}`,
874
+ );
875
+ return [head, ...lines].join('\n');
876
+ })
877
+ .join('\n\n');
878
+ },
879
+ },
880
+
881
+ {
882
+ name: 'changelog_get',
883
+ description: 'One changelog entry.',
884
+ inputSchema: {
885
+ type: 'object',
886
+ properties: { ...PROJECT_ARGUMENT, number: { type: 'integer' } },
887
+ required: ['number'],
888
+ },
889
+ handler: async (config, args) => {
890
+ const slug = await resolveProject(config, args.project);
891
+ const entry = await api(config, `/api/projects/${slug}/changelog/${args.number}`);
892
+ return formatChangelogEntry(entry);
893
+ },
894
+ },
895
+
896
+ {
897
+ name: 'changelog_add',
898
+ description:
899
+ 'Add a changelog entry. With no version it goes to Unreleased. Set breaking when the ' +
900
+ 'reader must act — that is the field they scan for.',
901
+ inputSchema: {
902
+ type: 'object',
903
+ properties: {
904
+ ...PROJECT_ARGUMENT,
905
+ category: { type: 'string', enum: ['ADDED', 'CHANGED', 'FIXED', 'REMOVED', 'SECURITY'] },
906
+ text: { type: 'string', description: 'What changed.' },
907
+ version: { type: 'string' },
908
+ breaking: { type: 'boolean' },
909
+ entryNumber: {
910
+ type: ['string', 'integer'],
911
+ description:
912
+ 'The roadmap card or issue this entry describes — R156 — by ref: R91, i91, or a '
913
+ + 'bare number for a roadmap card (R221). Set it. Until R156 the only link was the '
914
+ + 'ref you type into the prose, which cannot be queried, so a release could not '
915
+ + 'tell whether a card already had an entry and would write a second one beside '
916
+ + 'the first. Put the ref in the text as well; that is what a reader sees.',
917
+ },
918
+ },
919
+ required: ['category', 'text'],
920
+ },
921
+ handler: async (config, args) => {
922
+ const slug = await resolveProject(config, args.project);
923
+ const entry = await api(config, `/api/projects/${slug}/changelog`, {
924
+ method: 'POST',
925
+ body: pick(args, ['category', 'text', 'version', 'breaking', 'entryNumber']),
926
+ });
927
+ return `Added changelog entry ${entry.number} to ${entry.version}.\n\n${formatChangelogEntry(entry)}`;
928
+ },
929
+ },
930
+
931
+ {
932
+ name: 'changelog_update',
933
+ description:
934
+ 'Edit a changelog entry, including moving it to a release when the work ships. There is ' +
935
+ 'no delete — correct the text instead.',
936
+ inputSchema: {
937
+ type: 'object',
938
+ properties: {
939
+ ...PROJECT_ARGUMENT,
940
+ number: { type: 'integer' },
941
+ category: { type: 'string', enum: ['ADDED', 'CHANGED', 'FIXED', 'REMOVED', 'SECURITY'] },
942
+ text: { type: 'string' },
943
+ version: { type: 'string' },
944
+ breaking: { type: 'boolean' },
945
+ entryNumber: {
946
+ type: ['string', 'integer'],
947
+ description:
948
+ 'The roadmap card or issue this entry describes — R156 — by ref (R91, i91; a bare '
949
+ + 'number is a roadmap card). Set it on an older entry that has never been linked; '
950
+ + 'omitting it leaves whatever link is already there.',
951
+ },
952
+ },
953
+ required: ['number'],
954
+ },
955
+ handler: async (config, args) => {
956
+ const slug = await resolveProject(config, args.project);
957
+ const entry = await api(config, `/api/projects/${slug}/changelog/${args.number}`, {
958
+ method: 'PATCH',
959
+ body: pick(args, ['category', 'text', 'version', 'breaking', 'entryNumber']),
960
+ });
961
+ return `Updated changelog entry ${entry.number}.\n\n${formatChangelogEntry(entry)}`;
962
+ },
963
+ },
964
+
965
+ // --- orchestration: only meaningful inside a run -------------------------
966
+
967
+ {
968
+ name: 'task_current',
969
+ description:
970
+ 'What you are working on: the roadmap entry, THE PLAN agreed for it, its branch, and ' +
971
+ 'everything already said and asked on this run. Call it first, and again whenever you ' +
972
+ 'are unsure where you are — a resumed or confused session re-orients from this alone.',
973
+ inputSchema: { type: 'object', properties: {} },
974
+ handler: async (config) => {
975
+ const { runId, project } = await requireRun(config);
976
+ const run = await api(config, `/api/projects/${project}/runs/${runId}`);
977
+ // By ref — R221: a number alone is two cards, and the run says which.
978
+ // Falls back to the number for a platform older than R221's view.
979
+ const cardRef = run.entryRef ?? run.entryNumber;
980
+ const entry = await api(config, `/api/projects/${project}/roadmap/${cardRef}`);
981
+ const messages = await api(config, `/api/projects/${project}/runs/${runId}/messages`);
982
+ const questions = await api(config, `/api/projects/${project}/runs/${runId}/questions`);
983
+ // The discussion comes with the card, not only from roadmap_get. This is
984
+ // where a session reads what it is building, and an argument nobody sees
985
+ // at the start is an argument that gets had again.
986
+ const comments = await api(
987
+ config,
988
+ `/api/projects/${project}/roadmap/${cardRef}/comments`,
989
+ );
990
+ // Every session this card has already had — R38. Its own try, because a
991
+ // history that cannot be read is not a reason to fail the one call a
992
+ // resumed session re-orients from: the entry and the branch matter more,
993
+ // and the honest consequence of not knowing is to say nothing about it.
994
+ const history = await api(
995
+ config,
996
+ `/api/projects/${project}/roadmap/${cardRef}/runs`,
997
+ ).catch(() => []);
998
+ // R99. What this project IS, as a machine last read it out of
999
+ // `docs/brief/`. Its own try, like the history above and for the same
1000
+ // reason: a project nobody has interviewed answers 404, which is an
1001
+ // ordinary answer and not a reason to fail the one call a resumed session
1002
+ // re-orients from.
1003
+ const brief = await api(config, `/api/projects/${project}/brief`).catch(() => null);
1004
+ // R124. The plan agreed for this card, which for an implementation phase
1005
+ // is the instruction. Its own try, like the history and the brief and for
1006
+ // the same reason: a platform without it answers 404, and that must cost
1007
+ // the plan rather than the one call a resumed session re-orients from.
1008
+ //
1009
+ // It comes here as well as on the claim on purpose. The claim's copy is
1010
+ // in an opening prompt a long session may have scrolled past; this is the
1011
+ // call it makes when it has lost its place.
1012
+ const plans = await api(
1013
+ config,
1014
+ `/api/projects/${project}/roadmap/${cardRef}/plans`,
1015
+ ).catch(() => []);
1016
+
1017
+ const lines = [];
1018
+
1019
+ // R74. A card that was sent back leads with WHY, before the card's own
1020
+ // text — because the card's text is the original specification, and the
1021
+ // only honest reading of it alone is "build this". The instruction is:
1022
+ // fix what is listed; the branch already holds the work.
1023
+ if (entry.rejection) {
1024
+ lines.push(
1025
+ '=== THIS CARD WAS REVIEWED AND SENT BACK. FIX WHAT IS LISTED — DO NOT REBUILD IT ===',
1026
+ `The work is already on branch ${run.branch}. A previous session finished on it, ` +
1027
+ `and ${entry.rejection.decidedByEmail ?? 'the reviewer'} read it and said:`,
1028
+ '',
1029
+ entry.rejection.note,
1030
+ '',
1031
+ 'Address that. The card below is the original task, for context only.',
1032
+ '',
1033
+ );
1034
+ }
1035
+
1036
+ lines.push(
1037
+ `project: ${project}`,
1038
+ `branch: ${run.branch}`,
1039
+ `run: ${run.state}${run.runnerName ? ` on ${run.runnerName}` : ''}`,
1040
+ `started by ${run.startedByEmail}`,
1041
+ '',
1042
+ formatEntry(entry, { comments, plan: plans?.[0] ?? null }),
1043
+ );
1044
+
1045
+ // What happened the other times. A session is told the entry and the
1046
+ // branch but not that two previous runs on this card failed, which is
1047
+ // exactly what a session about to repeat them needs. This run is left
1048
+ // out: everything about it is already above and below.
1049
+ const before = history.filter((item) => item.run.id !== runId);
1050
+ if (before.length) {
1051
+ lines.push('', `--- this card has been worked on before (${before.length}) ---`);
1052
+ for (const { run: past, commits } of before) {
1053
+ lines.push(formatPastRun(past, commits));
1054
+ }
1055
+ }
1056
+
1057
+ if (messages.length) {
1058
+ lines.push('', '--- what you have reported so far ---');
1059
+ for (const message of messages) {
1060
+ lines.push(`[${message.kind}] ${message.body}`);
1061
+ }
1062
+ }
1063
+ if (questions.length) {
1064
+ lines.push('', '--- what you have asked ---');
1065
+ for (const question of questions) {
1066
+ lines.push(
1067
+ `Q: ${question.question}`,
1068
+ question.answered
1069
+ ? `A: ${question.answer} (${question.answeredByEmail})`
1070
+ : `A: still waiting (question_id ${question.id})`,
1071
+ );
1072
+ // Who it went to, and what they said. A resumed session that cannot
1073
+ // see this reads "still waiting" and concludes it has been ignored,
1074
+ // when in fact somebody passed it to a colleague an hour ago.
1075
+ for (const share of question.shares ?? []) {
1076
+ if (share.open) {
1077
+ lines.push(
1078
+ share.kind === 'DECIDE'
1079
+ ? ` handed to ${share.sharedWithEmail} to decide, by ${share.sharedByEmail}`
1080
+ : ` ${share.sharedByEmail} asked ${share.sharedWithEmail} what they think`,
1081
+ );
1082
+ }
1083
+ }
1084
+ for (const opinion of question.opinions ?? []) {
1085
+ lines.push(` ${opinion.authorEmail} thinks: ${opinion.body}`);
1086
+ }
1087
+ }
1088
+ }
1089
+ // Last, deliberately. The task is what this call is for and belongs at
1090
+ // the top; the brief is context, and a session that has already read it
1091
+ // once should not have to scroll past it to find out what it is doing.
1092
+ if (brief?.files?.length) {
1093
+ const index = brief.files.find((file) => file.path === brief.index) ?? brief.files[0];
1094
+ const rest = brief.files.filter((file) => file !== index).map((file) => file.path);
1095
+ lines.push(
1096
+ '',
1097
+ `--- what this project is (${brief.index}, read at `
1098
+ + `${(brief.writtenAtSha ?? '').slice(0, 8)}) ---`,
1099
+ index.body.trim(),
1100
+ );
1101
+ if (rest.length) {
1102
+ lines.push('', `The rest of the brief is in the checkout: ${rest.join(', ')}`);
1103
+ }
1104
+ if (brief.current === false) {
1105
+ lines.push('The default branch has moved since this was written. '
1106
+ + 'Trust the files over it.');
1107
+ }
1108
+ }
1109
+ return lines.join('\n');
1110
+ },
1111
+ },
1112
+
1113
+ {
1114
+ name: 'report',
1115
+ description:
1116
+ 'Say what is happening. `progress` as often as useful; `done` when the work is finished, ' +
1117
+ 'naming the branch and any PR; `blocked` when something stops you that a person must ' +
1118
+ 'resolve. done and blocked also end the run — you do not need a separate step.',
1119
+ inputSchema: {
1120
+ type: 'object',
1121
+ properties: {
1122
+ kind: { type: 'string', enum: ['progress', 'done', 'blocked'] },
1123
+ body: { type: 'string', description: 'Markdown. Say what actually happened.' },
1124
+ },
1125
+ required: ['kind', 'body'],
1126
+ },
1127
+ handler: async (config, args) => {
1128
+ const { runId, project } = await requireRun(config);
1129
+ const message = await api(config, `/api/projects/${project}/runs/${runId}/messages`, {
1130
+ method: 'POST',
1131
+ body: { kind: args.kind.toUpperCase(), body: args.body },
1132
+ });
1133
+
1134
+ // done and blocked END the run, and a run's token expires with it — so
1135
+ // there is no reading the run back afterwards. Say what happened from
1136
+ // what we know, and tell the agent its token is now spent, which is the
1137
+ // thing it most needs to hear.
1138
+ const ended = { DONE: 'FINISHED', BLOCKED: 'FAILED' }[message.kind];
1139
+ if (ended) {
1140
+ return (
1141
+ `Reported ${message.kind}. The run is ${ended} and this token has expired with it — ` +
1142
+ `there is nothing further to do here.`
1143
+ );
1144
+ }
1145
+
1146
+ const run = await api(config, `/api/projects/${project}/runs/${runId}`);
1147
+ return `Reported ${message.kind}. The run is ${run.state}.`;
1148
+ },
1149
+ },
1150
+
1151
+ {
1152
+ name: 'propose_entry',
1153
+ description:
1154
+ 'Record something an audit found, or a card a scoping session cut, as a proposed entry. ' +
1155
+ '`kind` is your opinion: "issue" ' +
1156
+ '(broken, unsafe, or loses data — with a severity) or "roadmap" (it should also do this, ' +
1157
+ 'or do it better — no severity). A PERSON decides, and may file it the other way — you ' +
1158
+ 'are not creating an entry, you are suggesting it. Default "issue", so an audit written ' +
1159
+ 'before there was a choice files what it always did. Severity is "critical" (broken, ' +
1160
+ 'unsafe, or loses data), "medium" (it will hurt, but not today) or "minor" (worth doing, ' +
1161
+ 'nobody is bleeding). `section` is where you think it belongs; a scoping session names ' +
1162
+ 'ONE for every card of the idea. A roadmap card MUST say what it starts after — `after`: ' +
1163
+ '`[]` for a card that starts after none, `"#2"` for the second card you proposed in this ' +
1164
+ 'run (the number this tool answered with), `"R12"` for a card already on the roadmap. ' +
1165
+ 'You can only name what has already been proposed, so file in build order. Write each ' +
1166
+ 'one as an entry would be written, and say where in the code you saw it. Only an audit ' +
1167
+ 'or a scoping session may use this.',
1168
+ inputSchema: {
1169
+ type: 'object',
1170
+ properties: {
1171
+ kind: {
1172
+ type: 'string',
1173
+ enum: ['issue', 'roadmap'],
1174
+ description: 'What you found: "issue" when something is broken, "roadmap" when the ' +
1175
+ 'code should also do something, or do it better. Default "issue".',
1176
+ },
1177
+ severity: {
1178
+ type: 'string',
1179
+ enum: ['critical', 'medium', 'minor'],
1180
+ description: 'Required for an issue, refused for a roadmap card.',
1181
+ },
1182
+ title: { type: 'string', description: 'Short enough to scan in a list.' },
1183
+ body: {
1184
+ type: 'string',
1185
+ description:
1186
+ 'Markdown: what and why, a **Build:** list, and a **Done when:** condition.',
1187
+ },
1188
+ section: {
1189
+ type: 'string',
1190
+ description:
1191
+ 'The roadmap section this belongs under, in your opinion. Optional. The person ' +
1192
+ 'accepting sees it as the default and may file it elsewhere. A scoping session ' +
1193
+ 'uses the same section on every card it cuts from one idea.',
1194
+ },
1195
+ after: {
1196
+ type: 'array',
1197
+ items: { type: 'string' },
1198
+ description:
1199
+ 'What this card starts after. Required for a roadmap card, refused on an issue. ' +
1200
+ '`[]` for a card that starts after none — the first. `"#2"` names the second ' +
1201
+ 'card you proposed in THIS run, by the number this tool answered with; it must ' +
1202
+ 'already have been proposed, so propose what a card depends on before the card. ' +
1203
+ '`"R12"` or `"i7"` names a card already on the roadmap. A person adds the cards ' +
1204
+ 'in this order, and a coding session on one waits until what it starts after has ' +
1205
+ 'landed.',
1206
+ },
1207
+ },
1208
+ required: ['title', 'body'],
1209
+ },
1210
+ handler: async (config, args) => {
1211
+ const { runId, project } = await requireRun(config);
1212
+ // R214. The kind is the audit's opinion; the API refuses an issue with
1213
+ // no severity and a card with one, each with a sentence, so neither is
1214
+ // checked here — and neither is `after`: a card without one is refused
1215
+ // with the sentence that says what to send.
1216
+ const proposal = await api(config, `/api/projects/${project}/runs/${runId}/proposals`, {
1217
+ method: 'POST',
1218
+ body: {
1219
+ kind: (args.kind ?? 'issue').toUpperCase(),
1220
+ severity: args.severity?.toUpperCase(),
1221
+ title: args.title,
1222
+ body: args.body,
1223
+ section: args.section,
1224
+ after: args.after,
1225
+ },
1226
+ });
1227
+ const as = proposal.kind === 'ROADMAP'
1228
+ ? 'a roadmap card'
1229
+ : `an issue (${proposal.severity})`;
1230
+ const under = proposal.section ? ` under "${proposal.section}"` : '';
1231
+ const startsAfter = proposal.kind !== 'ROADMAP' || !Array.isArray(proposal.after)
1232
+ ? ''
1233
+ : proposal.after.length
1234
+ ? ` Starts after ${proposal.after.join(', ')}.`
1235
+ : ' Starts after none.';
1236
+ return (
1237
+ `Proposed #${proposal.seq} as ${as}${under} — ${proposal.title}.${startsAfter} ` +
1238
+ `It is not on any board: somebody will decide.`
1239
+ );
1240
+ },
1241
+ },
1242
+
1243
+ {
1244
+ name: 'ask_user',
1245
+ description:
1246
+ 'Ask the person who started this run, and wait for their answer. Use it when a decision is ' +
1247
+ 'genuinely theirs — not to check work you can check yourself. Blocks for up to ten ' +
1248
+ 'minutes; if nobody has answered by then it returns a question_id for await_answer.',
1249
+ inputSchema: {
1250
+ type: 'object',
1251
+ properties: {
1252
+ question: { type: 'string' },
1253
+ options: {
1254
+ type: 'array',
1255
+ items: { type: 'string' },
1256
+ description: 'Optional one-click choices. They can still answer in free text.',
1257
+ },
1258
+ },
1259
+ required: ['question'],
1260
+ },
1261
+ handler: async (config, args) => {
1262
+ const { runId, project } = await requireRun(config);
1263
+ const asked = await api(config, `/api/projects/${project}/runs/${runId}/questions`, {
1264
+ method: 'POST',
1265
+ body: { question: args.question, options: args.options },
1266
+ });
1267
+
1268
+ const answered = await pollForAnswer(config, project, runId, asked.id, askTimeoutSeconds());
1269
+ if (answered) {
1270
+ return renderAnswer(answered);
1271
+ }
1272
+ return (
1273
+ `Nobody has answered yet. The run is WAITING_ON_USER and the question is in their ` +
1274
+ `inbox.\n\nCall await_answer with question_id ${asked.id} to keep waiting. Do not ` +
1275
+ `guess an answer and carry on — you asked because the decision was theirs.`
1276
+ );
1277
+ },
1278
+ },
1279
+
1280
+ {
1281
+ name: 'ask_group',
1282
+ description:
1283
+ 'Ask a ROUND of questions at once, and wait for all of them. Use it when you have several ' +
1284
+ 'things to settle that belong together — they arrive in the person\'s inbox under one ' +
1285
+ 'title, on one form, instead of interrupting them once per question. At most 12 in a ' +
1286
+ 'round; ask the rest in the next one. Blocks for up to ten minutes; if the round is ' +
1287
+ 'unfinished by then it returns a group_id for await_group.',
1288
+ inputSchema: {
1289
+ type: 'object',
1290
+ properties: {
1291
+ title: {
1292
+ type: 'string',
1293
+ description:
1294
+ 'What this round is called in their inbox. A CTO Interview uses "CTO Interview".',
1295
+ },
1296
+ intro: {
1297
+ type: 'string',
1298
+ description: 'One line above the questions saying what this round is about.',
1299
+ },
1300
+ questions: {
1301
+ type: 'array',
1302
+ maxItems: 12,
1303
+ items: {
1304
+ type: 'object',
1305
+ properties: {
1306
+ question: { type: 'string' },
1307
+ options: {
1308
+ type: 'array',
1309
+ items: { type: 'string' },
1310
+ description: 'Optional one-click choices. They can still answer in free text.',
1311
+ },
1312
+ },
1313
+ required: ['question'],
1314
+ },
1315
+ },
1316
+ },
1317
+ required: ['questions'],
1318
+ },
1319
+ handler: async (config, args) => {
1320
+ const { runId, project } = await requireRun(config);
1321
+ const asked = await api(config, `/api/projects/${project}/runs/${runId}/question-groups`, {
1322
+ method: 'POST',
1323
+ body: { title: args.title, intro: args.intro, questions: args.questions },
1324
+ });
1325
+
1326
+ const answered = await pollForGroup(config, project, runId, asked.id, askTimeoutSeconds());
1327
+ if (answered) {
1328
+ return renderRound(asked, answered);
1329
+ }
1330
+ return (
1331
+ `The round is unanswered. The run is WAITING_ON_USER and the questions are in their ` +
1332
+ `inbox under "${asked.title}".\n\nCall await_group with group_id ${asked.id} to keep ` +
1333
+ `waiting. Do not guess the answers and carry on — you asked because they were theirs.`
1334
+ );
1335
+ },
1336
+ },
1337
+
1338
+ {
1339
+ name: 'await_group',
1340
+ description:
1341
+ 'Resume waiting for a round ask_group handed back. It returns when EVERY question in the ' +
1342
+ 'round has been answered — they were asked together because the answers only make sense ' +
1343
+ 'together.',
1344
+ inputSchema: {
1345
+ type: 'object',
1346
+ properties: { group_id: { type: 'string' } },
1347
+ required: ['group_id'],
1348
+ },
1349
+ handler: async (config, args) => {
1350
+ const { runId, project } = await requireRun(config);
1351
+ const answered = await pollForGroup(
1352
+ config,
1353
+ project,
1354
+ runId,
1355
+ args.group_id,
1356
+ askTimeoutSeconds(),
1357
+ );
1358
+ if (answered) {
1359
+ return renderRound(null, answered);
1360
+ }
1361
+ return `Still unfinished. Call await_group again with group_id ${args.group_id}.`;
1362
+ },
1363
+ },
1364
+
1365
+ {
1366
+ name: 'interview_rounds',
1367
+ description:
1368
+ 'CTO INTERVIEW ONLY. Where this interview stands: rounds asked, rounds answered, and how ' +
1369
+ 'many you may ask. The budget is the platform\'s, not yours — asking past it is refused.',
1370
+ inputSchema: { type: 'object', properties: {} },
1371
+ handler: async (config) => {
1372
+ const { runId, project } = await requireRun(config);
1373
+ const where = await api(config, `/api/projects/${project}/runs/${runId}/interview`);
1374
+ return renderRounds(where);
1375
+ },
1376
+ },
1377
+
1378
+ {
1379
+ name: 'await_more_rounds',
1380
+ description:
1381
+ 'CTO INTERVIEW ONLY. You have asked every round you are allowed. Call this and WAIT: the ' +
1382
+ 'person is being shown two buttons — "I have more time", which gives you three more ' +
1383
+ 'rounds, and "finish here", which means write the brief from what has been answered. Do ' +
1384
+ 'not ask another round until this says you may.',
1385
+ inputSchema: { type: 'object', properties: {} },
1386
+ handler: async (config) => {
1387
+ const { runId, project } = await requireRun(config);
1388
+ const where = await api(config, `/api/projects/${project}/runs/${runId}/interview`);
1389
+
1390
+ const deadline = Date.now() + askTimeoutSeconds() * 1000;
1391
+ while (Date.now() < deadline) {
1392
+ const remaining = Math.ceil((deadline - Date.now()) / 1000);
1393
+ const response = await fetch(
1394
+ `${config.url}/api/projects/${project}/runs/${runId}/interview/decision` +
1395
+ `?asked=${where.roundsAsked}&wait=${Math.min(25, Math.max(1, remaining))}`,
1396
+ { headers: { authorization: `Bearer ${config.token}` } },
1397
+ );
1398
+ if (response.status === 200) {
1399
+ const decided = await response.json();
1400
+ return decided.finishNow
1401
+ ? 'They said FINISH HERE. Write the brief now, from what has been answered — not ' +
1402
+ 'from what you wish you had asked. Do not ask another round.'
1403
+ : `They have more time. ${renderRounds(decided)}`;
1404
+ }
1405
+ if (response.status !== 204) {
1406
+ const text = await response.text();
1407
+ throw new CawdevError(
1408
+ safeJson(text)?.message ?? `waiting failed: HTTP ${response.status}`,
1409
+ );
1410
+ }
1411
+ }
1412
+ return (
1413
+ 'Nobody has decided yet. Call await_more_rounds again to keep waiting, or write the ' +
1414
+ 'brief from what has been answered — which is never wrong, because everything you have ' +
1415
+ 'asked has been answered.'
1416
+ );
1417
+ },
1418
+ },
1419
+
1420
+ {
1421
+ name: 'await_answer',
1422
+ description:
1423
+ 'Resume waiting for a question ask_user handed back. Between the two you experience one ' +
1424
+ 'natural blocking ask; the person experiences an inbox item.',
1425
+ inputSchema: {
1426
+ type: 'object',
1427
+ properties: { question_id: { type: 'string' } },
1428
+ required: ['question_id'],
1429
+ },
1430
+ handler: async (config, args) => {
1431
+ const { runId, project } = await requireRun(config);
1432
+ const answered = await pollForAnswer(
1433
+ config,
1434
+ project,
1435
+ runId,
1436
+ args.question_id,
1437
+ askTimeoutSeconds(),
1438
+ );
1439
+ if (answered) {
1440
+ return renderAnswer(answered);
1441
+ }
1442
+ return `Still nothing. Call await_answer again with question_id ${args.question_id}.`;
1443
+ },
1444
+ },
1445
+
1446
+ {
1447
+ name: 'approve',
1448
+ description:
1449
+ 'INTERNAL — Claude Code calls this itself as --permission-prompt-tool when no rule covers ' +
1450
+ 'a tool call. Do not call it yourself: it decides whether YOUR next call is allowed, and ' +
1451
+ 'calling it directly asks a person a question about nothing.',
1452
+ inputSchema: {
1453
+ type: 'object',
1454
+ properties: {
1455
+ tool_name: { type: 'string' },
1456
+ input: { type: 'object' },
1457
+ tool_use_id: { type: 'string' },
1458
+ },
1459
+ required: ['tool_name', 'input'],
1460
+ },
1461
+ handler: async (config, args) => decide(config, args),
1462
+ },
1463
+ ];
1464
+
1465
+ /**
1466
+ * The permission decision, as Claude Code reads it — R51.
1467
+ *
1468
+ * <p>Returned as JSON text, which is the wire format the CLI expects from a
1469
+ * permission-prompt tool: `{behavior: "allow", updatedInput}` or
1470
+ * `{behavior: "deny", message}`.
1471
+ *
1472
+ * THIS FUNCTION NEVER THROWS. Every other tool here turns a failure into an
1473
+ * error result the agent reads and works around; this one cannot. A malformed
1474
+ * answer to a permission question is not a refusal — it is a session that
1475
+ * stops without saying why, which is the failure R51 exists to end. So an
1476
+ * unreachable platform, a bad response, anything at all, comes back as a deny
1477
+ * carrying the reason.
1478
+ */
1479
+ async function decide(config, args) {
1480
+ const toolName = String(args.tool_name ?? '');
1481
+ const input = args.input && typeof args.input === 'object' ? args.input : {};
1482
+
1483
+ try {
1484
+ const { runId, project } = await requireRun(config);
1485
+
1486
+ // What this project has already decided, filtered by what THIS MACHINE is
1487
+ // willing to have applied with nobody watching. The ceiling is enforced
1488
+ // here as well as at spawn because a rule added while the session was
1489
+ // running has never been through the runner at all.
1490
+ //
1491
+ // ITS OWN TRY, and this is the whole point of it: reading the rules is an
1492
+ // optimisation — "has somebody already said yes to this" — and a failure to
1493
+ // read them is a fact about the platform, not about this call. Sharing the
1494
+ // catch below made an unreachable rules endpoint deny every single call
1495
+ // without ever asking anybody, because the POST that raises the question
1496
+ // sits after this line. A run against an API too old to have the endpoint
1497
+ // reported itself blocked on npm, ng and most of Bash for a whole session,
1498
+ // and nothing appeared in anyone's inbox. Unreadable rules means no rule
1499
+ // applies, which means ask — the direction everything here fails in.
1500
+ // R110. The shield FIRST, and the ordering is the point — see shieldSays.
1501
+ // A hit does not deny; it skips the rules and goes straight to a person,
1502
+ // and it is recorded so somebody can see what was stopped.
1503
+ const stopped = shieldSays(shieldPolicy(), toolName, input);
1504
+ if (stopped) {
1505
+ await api(config, `/api/projects/${project}/runs/${runId}/blocks`, {
1506
+ method: 'POST',
1507
+ body: { kind: stopped.kind, detail: `${stopped.reason}: ${stopped.detail}` },
1508
+ }).catch(() => {
1509
+ // Recording is bookkeeping. Failing to record must not turn a question
1510
+ // into a denial — the person still gets asked below, which is the part
1511
+ // that matters.
1512
+ });
1513
+ }
1514
+
1515
+ const live = stopped
1516
+ ? []
1517
+ : (await liveRules(config, project))
1518
+ .filter((pattern) => withinCeiling(grantable(), pattern));
1519
+
1520
+ const covered = coveredBy(live, toolName, input);
1521
+ if (covered) {
1522
+ return allow(input, `covered by this project's rule ${covered}`);
1523
+ }
1524
+
1525
+ // What somebody already allowed for the rest of THIS run — R60.
1526
+ //
1527
+ // Deliberately NOT filtered by the ceiling, and it is the one place in this
1528
+ // file where that is true. R51's ceiling is about rules that apply when
1529
+ // nobody is watching: a project rule reaches sessions that have not started
1530
+ // yet, so the machine's owner has the last word on it. A session grant
1531
+ // reaches no session but this one, was made by a person looking at this
1532
+ // command, and dies when the run does. Filtering it would leave the console
1533
+ // offering a button that does nothing on every machine with a narrow
1534
+ // grantable — which is the default, and the reason this session stopped.
1535
+ //
1536
+ // Its own try, like liveRules and for the same reason: not knowing what was
1537
+ // granted is a fact about the platform, not about this call, and the honest
1538
+ // consequence is to ask again rather than to deny.
1539
+ const granted = stopped
1540
+ ? null
1541
+ : coveredBy(await sessionRules(config, project, runId), toolName, input);
1542
+ if (granted) {
1543
+ return allow(input, `allowed for this session by ${granted}`);
1544
+ }
1545
+
1546
+ // The exact-command rule, for the calls no wildcard can be written from —
1547
+ // R135. Two decisions in these two lines:
1548
+ //
1549
+ // NULL WHEN THE SHIELD STOPPED IT. A shield hit skips the stored rules on
1550
+ // every future call by design, so a durable button here would write a rule
1551
+ // that never applies — R126's "a button that produces a refusal should not
1552
+ // have been drawn".
1553
+ //
1554
+ // THE CEILING IS MEASURED HERE, on the machine, with the same matcher that
1555
+ // will enforce it. The platform does not know this machine's `grantable`
1556
+ // and should not: shipping it to the console would put a third copy of
1557
+ // this matcher in TypeScript, and the two would drift the way the runner
1558
+ // and the server would without this file.
1559
+ const exact = stopped ? null : exactRuleFor(toolName, input);
1560
+
1561
+ const asked = await api(config, `/api/projects/${project}/runs/${runId}/approvals`, {
1562
+ method: 'POST',
1563
+ body: {
1564
+ toolName,
1565
+ toolInput: JSON.stringify(input),
1566
+ summary: stopped
1567
+ ? `${summaryOf(toolName, input)} — stopped by the shield: ${stopped.reason}`
1568
+ : summaryOf(toolName, input),
1569
+ suggestion: suggestionFor(toolName, input, { skillServers: skillServers() }),
1570
+ exactPattern: exact,
1571
+ exactWithinCeiling: exact ? withinCeiling(grantable(), exact) : false,
1572
+ toolUseId: args.tool_use_id,
1573
+ },
1574
+ });
1575
+
1576
+ const decided = await pollForDecision(config, project, runId, asked.id);
1577
+ if (decided?.state === 'ALLOWED') {
1578
+ return allow(input, decided.reason ?? 'allowed by a person');
1579
+ }
1580
+ if (decided?.state === 'DENIED') {
1581
+ return deny(
1582
+ `${decided.reason ?? 'A person refused this.'} Do not try to work around it — report ` +
1583
+ `blocked and say what you needed, or ask for a different approach.`,
1584
+ );
1585
+ }
1586
+ // EXPIRED, or the poll gave up before the platform expired it. Same answer
1587
+ // either way, and the difference is on the run for whoever reads it later.
1588
+ return deny(
1589
+ `Nobody answered this permission request in time. Do not retry it in a loop: report ` +
1590
+ `blocked, say exactly what you needed to run and why, and let a person allow it.`,
1591
+ );
1592
+ } catch (failure) {
1593
+ return deny(
1594
+ `cawdev could not be asked whether this is allowed (${failure.message}). Treating that as ` +
1595
+ `no. If this persists, report blocked rather than retrying.`,
1596
+ );
1597
+ }
1598
+ }
1599
+
1600
+ function allow(updatedInput, reason) {
1601
+ // `updatedInput` echoed back unchanged. The hook exists to say yes or no,
1602
+ // not to rewrite what the agent was about to do behind its back.
1603
+ return JSON.stringify({ behavior: 'allow', updatedInput, reason });
1604
+ }
1605
+
1606
+ function deny(message) {
1607
+ return JSON.stringify({ behavior: 'deny', message });
1608
+ }
1609
+
1610
+ /**
1611
+ * The project's stored rules, or none when they cannot be read.
1612
+ *
1613
+ * Never throws. The caller is deciding a permission and must reach the point
1614
+ * where it asks a person; an endpoint that 404s, times out or answers with
1615
+ * nonsense is not an answer about this call, so it counts as "no rule covers
1616
+ * it". The session then asks, which is what it would have done anyway had the
1617
+ * project stored nothing.
1618
+ */
1619
+ async function liveRules(config, project) {
1620
+ try {
1621
+ const rules = await api(config, `/api/projects/${project}/tool-rules`);
1622
+ return (rules ?? []).map((rule) => rule?.pattern).filter((pattern) => typeof pattern === 'string');
1623
+ } catch {
1624
+ return [];
1625
+ }
1626
+ }
1627
+
1628
+ /**
1629
+ * What has already been allowed for the rest of this run, or none — R60.
1630
+ *
1631
+ * Never throws, for the reason `liveRules` does not: the caller is deciding a
1632
+ * permission and must reach the point where it asks a person. An endpoint that
1633
+ * 404s — an older API, which is the normal case during an upgrade — is not an
1634
+ * answer about this call, so it counts as "nothing has been granted" and the
1635
+ * session asks. That is what it would have done anyway before R60 existed.
1636
+ */
1637
+ async function sessionRules(config, project, runId) {
1638
+ try {
1639
+ const rules = await api(config, `/api/projects/${project}/runs/${runId}/tool-rules`);
1640
+ return (rules ?? []).map((rule) => rule?.pattern).filter((p) => typeof p === 'string');
1641
+ } catch {
1642
+ return [];
1643
+ }
1644
+ }
1645
+
1646
+ /**
1647
+ * What this machine is willing to have applied unattended.
1648
+ *
1649
+ * Put in the environment by the runner when it spawns the session. Absent
1650
+ * means an empty ceiling, which is the safe reading: no stored rule applies on
1651
+ * its own and every call is asked about. A machine that wants otherwise says
1652
+ * so in its own config file, on the machine, by its owner.
1653
+ */
1654
+ function grantable() {
1655
+ try {
1656
+ const parsed = JSON.parse(process.env.CAWDEV_GRANTABLE ?? '[]');
1657
+ return Array.isArray(parsed) ? parsed : [];
1658
+ } catch {
1659
+ return [];
1660
+ }
1661
+ }
1662
+
1663
+ /**
1664
+ * Which of this session's MCP servers are skills — R76.
1665
+ *
1666
+ * Put in the environment by the runner, which is the only thing that knows: it
1667
+ * composed the config. Absent means none, which is the safe reading — every
1668
+ * suggestion is then the single tool, which is narrower than a server.
1669
+ *
1670
+ * It changes ONE thing: the pattern offered to the person a stopped session is
1671
+ * waiting on. A project turned CodeGraph on as one capability, so the offer is
1672
+ * `mcp__codegraph` rather than the tool that happened to be called first. It
1673
+ * grants nothing by itself — a person still says yes, and R60's session rule is
1674
+ * what carries it.
1675
+ */
1676
+ /**
1677
+ * What this project's shield says — R110.
1678
+ *
1679
+ * <p>Handed down from the claim through the runner's environment, like
1680
+ * `CAWDEV_SKILL_SERVERS`. Unreadable means GUARDED, which is the opposite of
1681
+ * how everything else in this file fails: an unreadable rule means "ask", and an
1682
+ * unreadable shield means "ask" too — they agree, and the reason is the same.
1683
+ * The direction that is never taken is "allow".
1684
+ */
1685
+ function shieldPolicy() {
1686
+ try {
1687
+ const parsed = JSON.parse(process.env.CAWDEV_SHIELD ?? '{}');
1688
+ return {
1689
+ blockSecrets: parsed.blockSecrets !== false,
1690
+ blockDestructive: parsed.blockDestructive !== false,
1691
+ pathScope: Array.isArray(parsed.pathScope) ? parsed.pathScope : null,
1692
+ root: process.env.CAWDEV_WORKSPACE ?? process.cwd(),
1693
+ };
1694
+ } catch {
1695
+ return { blockSecrets: true, blockDestructive: true, pathScope: null, root: process.cwd() };
1696
+ }
1697
+ }
1698
+
1699
+ /**
1700
+ * What the shield says about this call, or null — R110.
1701
+ *
1702
+ * <p>Checked BEFORE the project's rules, and that ordering is the entry. A rule
1703
+ * reading `Bash(rm *)` is a rule somebody wrote meaning "may tidy up", and its
1704
+ * literal effect includes `rm -rf /`. If the shield ran after it, the rule would
1705
+ * silently cover the one command it exists to stop.
1706
+ *
1707
+ * <p>The result is a QUESTION, not a refusal: this returns a reason, and the
1708
+ * caller falls through to asking a person. A shield that ended runs is a shield
1709
+ * people turn off.
1710
+ */
1711
+ function shieldSays(policy, toolName, input) {
1712
+ if (policy.blockDestructive && toolName === 'Bash') {
1713
+ const found = findDestructive(String(input.command ?? ''));
1714
+ if (found) {
1715
+ return { kind: 'DESTRUCTIVE', reason: found.name, detail: found.redacted };
1716
+ }
1717
+ }
1718
+ const path = input.file_path ?? input.path ?? input.notebook_path;
1719
+ if (path && !withinScope(String(path), policy.root, policy.pathScope)) {
1720
+ return {
1721
+ kind: 'SCOPE',
1722
+ reason: policy.pathScope
1723
+ ? 'outside the paths this project allows'
1724
+ : 'outside this run\'s checkout',
1725
+ detail: String(path),
1726
+ };
1727
+ }
1728
+ return null;
1729
+ }
1730
+
1731
+ function skillServers() {
1732
+ try {
1733
+ const parsed = JSON.parse(process.env.CAWDEV_SKILL_SERVERS ?? '[]');
1734
+ return Array.isArray(parsed) ? parsed.filter((each) => typeof each === 'string') : [];
1735
+ } catch {
1736
+ return [];
1737
+ }
1738
+ }
1739
+
1740
+ /**
1741
+ * Waits for somebody to decide, for as long as the platform will hold it open.
1742
+ *
1743
+ * Bounded a little beyond the platform's own expiry so the two cannot both be
1744
+ * waiting on each other: if the sweep is late, this gives up and denies, which
1745
+ * is the same answer the sweep would have produced.
1746
+ */
1747
+ async function pollForDecision(config, project, runId, approvalId) {
1748
+ const deadline = Date.now() + approvalTimeoutSeconds() * 1000;
1749
+ while (Date.now() < deadline) {
1750
+ const remaining = Math.ceil((deadline - Date.now()) / 1000);
1751
+ const response = await fetch(
1752
+ `${config.url}/api/projects/${project}/runs/${runId}/approvals/${approvalId}/decision` +
1753
+ `?wait=${Math.min(25, Math.max(1, remaining))}`,
1754
+ { headers: { authorization: `Bearer ${config.token}` } },
1755
+ );
1756
+ if (response.status === 200) {
1757
+ return await response.json();
1758
+ }
1759
+ if (response.status !== 204) {
1760
+ const text = await response.text();
1761
+ throw new CawdevError(safeJson(text)?.message ?? `waiting failed: HTTP ${response.status}`);
1762
+ }
1763
+ // 204 means "still pending" — ask again.
1764
+ }
1765
+ return null;
1766
+ }
1767
+
1768
+ /** A little past the platform's own fifteen-minute expiry. Overridable for tests. */
1769
+ function approvalTimeoutSeconds() {
1770
+ const configured = Number(process.env.CAWDEV_APPROVAL_TIMEOUT_SECONDS);
1771
+ return Number.isFinite(configured) && configured > 0 ? configured : 960;
1772
+ }
1773
+
1774
+ // There is no roadmap_delete or changelog_delete, and there will not be. The
1775
+ // API has no such endpoint either: DECLINED with a reason is the only exit.
1776
+ // The same goes for comments — nothing here removes one, and nothing there
1777
+ // does either.
1778
+ //
1779
+ // Nor is there a sprint_open, sprint_close or sprint_rename — R257. Opening,
1780
+ // closing and renaming a sprint are a person's acts on the page, and the API
1781
+ // says so structurally (POST/PATCH /sprints take a session, not a token). An
1782
+ // agent's whole reach here is reading a card's sprint and filing a card into
1783
+ // one with roadmap_create / roadmap_update `sprint`.
1784
+
1785
+ function pick(source, keys) {
1786
+ const out = {};
1787
+ for (const key of keys) {
1788
+ if (source[key] !== undefined) out[key] = source[key];
1789
+ }
1790
+ return out;
1791
+ }
1792
+
1793
+ /**
1794
+ * The project's code map, or null if nobody has taken one — R77.
1795
+ *
1796
+ * <p>Null rather than a throw: a project nobody has mapped is the ordinary
1797
+ * case, not an error, and a tool that fails there teaches the session to stop
1798
+ * calling it.
1799
+ */
1800
+ async function codeMapOrNothing(config, slug) {
1801
+ try {
1802
+ const stored = await api(config, `/api/projects/${slug}/code-map`);
1803
+ const graph = JSON.parse(stored.graph);
1804
+ return {
1805
+ ...stored,
1806
+ files: Array.isArray(graph.files) ? graph.files : [],
1807
+ edges: Array.isArray(graph.edges) ? graph.edges : [],
1808
+ };
1809
+ } catch {
1810
+ return null;
1811
+ }
1812
+ }
1813
+
1814
+ /** Everything at or under a directory. */
1815
+ function under(path, directory) {
1816
+ return !directory || path === directory || path.startsWith(`${directory}/`);
1817
+ }
1818
+
1819
+ /**
1820
+ * The map as a session should read it: directories, sizes, and what they lean on.
1821
+ *
1822
+ * <p>Directories rather than files, because four hundred filenames is the thing
1823
+ * the session was going to produce for itself and the reason this tool exists.
1824
+ * A directory with what it depends on is orientation; a file list is a `find`.
1825
+ */
1826
+ function formatCodeMap(map, directory) {
1827
+ const dirs = new Map();
1828
+ for (const file of map.files) {
1829
+ if (!under(file.path, directory)) continue;
1830
+ dirs.set(file.dir, (dirs.get(file.dir) ?? 0) + 1);
1831
+ }
1832
+ if (!dirs.size) {
1833
+ return directory
1834
+ ? `Nothing under ${directory}. Check the path — this map has ${map.files.length} files.`
1835
+ : 'This project has no source files on the map.';
1836
+ }
1837
+
1838
+ // Folded to directories, so "frontend leans on core" is one line rather than
1839
+ // forty. The count is what makes it worth reading: a dependency used once and
1840
+ // one used ninety times are different facts about a design.
1841
+ const between = new Map();
1842
+ for (const edge of map.edges) {
1843
+ if (!under(edge.from, directory) || !under(edge.to, directory)) continue;
1844
+ const from = edge.from.split('/').slice(0, -1).join('/');
1845
+ const to = edge.to.split('/').slice(0, -1).join('/');
1846
+ if (from === to) continue;
1847
+ const key = `${from} -> ${to}`;
1848
+ between.set(key, (between.get(key) ?? 0) + edge.weight);
1849
+ }
1850
+
1851
+ const lines = [
1852
+ `${map.files.length} files in ${dirs.size} directories`
1853
+ + (directory ? ` under ${directory}` : '')
1854
+ + (map.headSha ? `, mapped at ${map.headSha.slice(0, 7)}` : '')
1855
+ + (map.stale ? ' (the branch has moved since)' : ''),
1856
+ '',
1857
+ 'DIRECTORIES, largest first:',
1858
+ ];
1859
+ for (const [dir, count] of [...dirs.entries()].sort((a, b) => b[1] - a[1]).slice(0, 60)) {
1860
+ lines.push(` ${String(count).padStart(4)} ${dir || '(root)'}`);
1861
+ }
1862
+
1863
+ const heavy = [...between.entries()].sort((a, b) => b[1] - a[1]).slice(0, 40);
1864
+ if (heavy.length) {
1865
+ lines.push('', 'WHAT LEANS ON WHAT, heaviest first:');
1866
+ for (const [pair, weight] of heavy) {
1867
+ lines.push(` ${String(weight).padStart(4)} ${pair}`);
1868
+ }
1869
+ }
1870
+ return lines.join('\n');
1871
+ }
1872
+
1873
+ /**
1874
+ * One file's dependencies, both ways.
1875
+ *
1876
+ * <p>The second list is the one worth having: what would break. Searching for a
1877
+ * filename finds the string, misses re-exports and relative paths written from
1878
+ * a different directory, and cannot tell an import from a mention in a comment.
1879
+ */
1880
+ function formatFileDeps(map, path) {
1881
+ const known = map.files.some((file) => file.path === path);
1882
+ if (!known) {
1883
+ const near = map.files
1884
+ .filter((file) => file.path.endsWith(`/${path.split('/').pop()}`))
1885
+ .slice(0, 8)
1886
+ .map((file) => ` ${file.path}`);
1887
+ return `${path} is not on this map.`
1888
+ + (near.length ? `\n\nDid you mean:\n${near.join('\n')}` : '');
1889
+ }
1890
+
1891
+ const imports = map.edges.filter((edge) => edge.from === path);
1892
+ const importers = map.edges.filter((edge) => edge.to === path);
1893
+ const lines = [path, ''];
1894
+
1895
+ lines.push(imports.length ? 'IT IMPORTS:' : 'It imports nothing inside this repository.');
1896
+ for (const edge of imports.sort((a, b) => b.weight - a.weight)) {
1897
+ lines.push(` ${edge.to}${edge.weight > 1 ? ` (${edge.weight}x)` : ''}`);
1898
+ }
1899
+
1900
+ lines.push('');
1901
+ lines.push(importers.length
1902
+ ? `IMPORTED BY ${importers.length} — this is what changing it reaches:`
1903
+ : 'Nothing in this repository imports it.');
1904
+ for (const edge of importers.sort((a, b) => b.weight - a.weight)) {
1905
+ lines.push(` ${edge.from}${edge.weight > 1 ? ` (${edge.weight}x)` : ''}`);
1906
+ }
1907
+ return lines.join('\n');
1908
+ }
1909
+
1910
+ /**
1911
+ * What to call a card — R127. The server says, in `ref`; this is the fallback
1912
+ * for anything that predates it.
1913
+ */
1914
+ function refOf(entry) {
1915
+ return entry.ref ?? ((entry.kind === 'ISSUE' ? 'i' : 'R') + entry.number);
1916
+ }
1917
+
1918
+ function formatEntry(entry, { brief, comments, plan }) {
1919
+ const lines = [`${refOf(entry)} — ${entry.title}`, ` status: ${entry.statusDisplay}`];
1920
+ if (entry.branch) lines.push(` branch: ${entry.branch}`);
1921
+ if (entry.merge) lines.push(` merged: ${entry.merge}`);
1922
+ if (entry.version) lines.push(` version: ${entry.version}`);
1923
+ if (entry.declinedReason) lines.push(` declined because: ${entry.declinedReason}`);
1924
+ if (entry.section) lines.push(` section: ${entry.section}`);
1925
+ // R257. Always printed when the card has one, so a reader's shape does not
1926
+ // depend on which tool printed the card; a closed sprint says so.
1927
+ if (entry.sprint) {
1928
+ lines.push(
1929
+ ` sprint: ${entry.sprint.ref} ${entry.sprint.name}${entry.sprint.state === 'CLOSED' ? ' (closed)' : ''}`,
1930
+ );
1931
+ }
1932
+ if (entry.related?.length) {
1933
+ // Each id written the way its own card is written, when the API said so.
1934
+ lines.push(
1935
+ ` related: ${entry.related
1936
+ .map((n, index) => entry.relatedRefs?.[index] ?? `R${n}`)
1937
+ .join(', ')}`,
1938
+ );
1939
+ }
1940
+ if (entry.after?.length) {
1941
+ // R181. A coding run on this card is held until each of these lands; the
1942
+ // ones still holding it are marked, so a session reading the card knows
1943
+ // why its own start would wait.
1944
+ lines.push(
1945
+ ` after: ${entry.after
1946
+ .map((card) => (card.landed ? card.ref : `${card.ref} (waiting)`))
1947
+ .join(', ')}`,
1948
+ );
1949
+ }
1950
+ // Said in a survey, where the comments themselves are not fetched: an entry
1951
+ // with an argument attached should be visibly different from one without,
1952
+ // even in a list. Omitted at zero rather than written as "comments: 0".
1953
+ if (brief && entry.commentCount) lines.push(` comments: ${entry.commentCount}`);
1954
+ // R38. Both omitted at zero and at null: a card nobody has run anything on,
1955
+ // written by a person, is the ordinary case and says nothing about itself.
1956
+ if (entry.runCount) lines.push(` runs so far: ${entry.runCount}`);
1957
+ if (entry.createdBy) {
1958
+ lines.push(` written by: a ${entry.createdBy.profile} session, `
1959
+ + `under ${entry.createdBy.startedByEmail}`);
1960
+ }
1961
+ if (!brief && entry.body) lines.push('', entry.body);
1962
+ // R124, between the body and the discussion — the order they are read in:
1963
+ // what we want, how we mean to get it, and the argument about both.
1964
+ if (!brief && plan?.body) lines.push('', formatPlan(plan));
1965
+ if (comments?.length) lines.push('', formatComments(comments));
1966
+ return lines.join('\n');
1967
+ }
1968
+
1969
+ /**
1970
+ * The plan somebody agreed for this card — R124.
1971
+ *
1972
+ * <p>The CURRENT one and no history, which is the same restraint
1973
+ * {@link formatPastRun} shows about transcripts: plans are append-only and a
1974
+ * card that was re-planned four times would fill the context of the session
1975
+ * this is meant to orient. What was thought before is on the card's page.
1976
+ *
1977
+ * <p><strong>A planning session can reach this, and that is deliberate.</strong>
1978
+ * The claim does not carry the old plan into a plan phase's prompt — leading a
1979
+ * session with its predecessor's answer produces an echo rather than a second
1980
+ * opinion. Being able to LOOK is a different thing, and there is a case that
1981
+ * settles it: a person's correction is written as a new plan, and a re-planning
1982
+ * session blind to that would rewrite the very thing somebody just fixed.
1983
+ */
1984
+ function formatPlan(plan) {
1985
+ const who = plan.authorEmail ? plan.authorEmail : 'a plan session';
1986
+ const written = plan.baseCommit
1987
+ ? `${plan.createdAt} by ${who}, against ${plan.baseCommit.slice(0, 10)}`
1988
+ : `${plan.createdAt} by ${who}`;
1989
+ return [`--- the plan (${written}) ---`, plan.body].join('\n');
1990
+ }
1991
+
1992
+ /**
1993
+ * One earlier attempt at this card — R38.
1994
+ *
1995
+ * How it ended first, because that is the whole reason to read it: a card that
1996
+ * failed twice on the same branch is telling you something the status does not.
1997
+ * The commits are named rather than counted — a resumed session wants to know
1998
+ * whether the work it is about to do is already sitting on that branch.
1999
+ *
2000
+ * Deliberately no transcript. It is minutes of reading, it is on the run's own
2001
+ * page, and putting nine of them here would fill the context of the session
2002
+ * this is supposed to orient.
2003
+ */
2004
+ function formatPastRun(past, commits = []) {
2005
+ const took = past.startedAt && past.finishedAt
2006
+ ? `, ${Math.round((Date.parse(past.finishedAt) - Date.parse(past.startedAt)) / 60000)}m`
2007
+ : '';
2008
+ const lines = [
2009
+ `[${past.state}${took}] ${past.createdAt ?? ''} on ${past.branch ?? '(no branch)'}`
2010
+ + ` — ${past.model ?? 'the runner default'}, started by ${past.startedByEmail}`,
2011
+ ];
2012
+ if (past.exitSummary) lines.push(` ended: ${past.exitSummary}`);
2013
+ // Whether it left the machine at all: commits that were never pushed are not
2014
+ // on the branch a later session checks out.
2015
+ if (past.prUrl) lines.push(` pull request: ${past.prUrl}`);
2016
+ else if (past.pushState) lines.push(` push: ${past.pushState}`);
2017
+ for (const commit of commits) {
2018
+ lines.push(` ${commit.sha.slice(0, 8)} ${commit.subject}`);
2019
+ }
2020
+ if (!commits.length) lines.push(' committed nothing');
2021
+ return lines.join('\n');
2022
+ }
2023
+
2024
+ /**
2025
+ * The discussion, oldest first.
2026
+ *
2027
+ * A run's comment is attributed to the run — "a session" — because that is who
2028
+ * said it; the account it went out under is named too, since a reader deciding
2029
+ * how much weight to give an argument wants both.
2030
+ */
2031
+ function formatComments(comments) {
2032
+ const lines = [`--- the discussion (${comments.length}) ---`];
2033
+ for (const comment of comments) {
2034
+ const who = comment.authorRunId
2035
+ ? `a session, under ${comment.authorEmail}`
2036
+ : comment.authorEmail;
2037
+ lines.push(
2038
+ `[${comment.createdAt}] ${who}${comment.editedAt ? ' (edited)' : ''}`,
2039
+ comment.body,
2040
+ '',
2041
+ );
2042
+ }
2043
+ return lines.join('\n').trimEnd();
2044
+ }
2045
+
2046
+ function formatChangelogEntry(entry) {
2047
+ return (
2048
+ `[${entry.number}] ${entry.version} — ${entry.category}` +
2049
+ `${entry.breaking ? ' (BREAKING)' : ''}\n ${entry.text}`
2050
+ );
2051
+ }
2052
+
2053
+ // --- JSON-RPC over stdio ----------------------------------------------------
2054
+
2055
+ function send(message) {
2056
+ process.stdout.write(`${JSON.stringify(message)}\n`);
2057
+ }
2058
+
2059
+ function reply(id, result) {
2060
+ send({ jsonrpc: '2.0', id, result });
2061
+ }
2062
+
2063
+ function replyError(id, code, message) {
2064
+ send({ jsonrpc: '2.0', id, error: { code, message } });
2065
+ }
2066
+
2067
+ async function handle(message) {
2068
+ const { id, method, params } = message;
2069
+
2070
+ switch (method) {
2071
+ case 'initialize':
2072
+ return reply(id, {
2073
+ // Echo the client's revision when it names one: an agreed-on version we
2074
+ // both understand beats insisting on ours.
2075
+ protocolVersion: params?.protocolVersion ?? PROTOCOL_VERSION,
2076
+ capabilities: { tools: {} },
2077
+ serverInfo: { name: NAME, version: VERSION },
2078
+ });
2079
+
2080
+ case 'notifications/initialized':
2081
+ case 'notifications/cancelled':
2082
+ return; // Notifications carry no id and expect no reply.
2083
+
2084
+ case 'ping':
2085
+ return reply(id, {});
2086
+
2087
+ case 'tools/list':
2088
+ return reply(id, {
2089
+ tools: TOOLS.map(({ name, description, inputSchema }) => ({
2090
+ name,
2091
+ description,
2092
+ inputSchema,
2093
+ })),
2094
+ });
2095
+
2096
+ case 'tools/call': {
2097
+ const tool = TOOLS.find((candidate) => candidate.name === params?.name);
2098
+ if (!tool) {
2099
+ return replyError(id, -32602, `No such tool: ${params?.name}`);
2100
+ }
2101
+ // Configuration is re-read here, per call, on purpose. See readConfig.
2102
+ const config = await readConfig();
2103
+ try {
2104
+ const text = await tool.handler(config, params.arguments ?? {});
2105
+ return reply(id, { content: [{ type: 'text', text }] });
2106
+ } catch (failure) {
2107
+ // A refusal is a *result*, not a transport error: the agent should read
2108
+ // it, understand which rule it hit, and try something else — not see
2109
+ // the tool call itself fail.
2110
+ return reply(id, {
2111
+ content: [{ type: 'text', text: failure.message }],
2112
+ isError: true,
2113
+ });
2114
+ }
2115
+ }
2116
+
2117
+ default:
2118
+ if (id === undefined) return; // Unknown notification: ignore.
2119
+ return replyError(id, -32601, `Method not found: ${method}`);
2120
+ }
2121
+ }
2122
+
2123
+ const input = createInterface({ input: process.stdin });
2124
+
2125
+ // In-flight calls, so closing stdin does not cut off a reply that is still
2126
+ // being computed. A piped session — printf ... | node server.mjs — closes stdin
2127
+ // the moment it has written, which is *before* any HTTP round trip has
2128
+ // returned. Exiting on close loses those replies, and the transcript shows a
2129
+ // request with no response.
2130
+ let inFlight = 0;
2131
+ let inputClosed = false;
2132
+
2133
+ function exitWhenDone() {
2134
+ if (inputClosed && inFlight === 0) {
2135
+ process.exit(0);
2136
+ }
2137
+ }
2138
+
2139
+ input.on('line', (line) => {
2140
+ const trimmed = line.trim();
2141
+ if (!trimmed) return;
2142
+
2143
+ let message;
2144
+ try {
2145
+ message = JSON.parse(trimmed);
2146
+ } catch {
2147
+ return replyError(null, -32700, 'Parse error: each message must be one line of JSON.');
2148
+ }
2149
+
2150
+ inFlight += 1;
2151
+ handle(message)
2152
+ .catch((failure) => replyError(message.id ?? null, -32603, failure.message))
2153
+ .finally(() => {
2154
+ inFlight -= 1;
2155
+ exitWhenDone();
2156
+ });
2157
+ });
2158
+
2159
+ // stdin closing is how an MCP client says goodbye — once we have answered.
2160
+ input.on('close', () => {
2161
+ inputClosed = true;
2162
+ exitWhenDone();
2163
+ });