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/README.md +175 -0
- package/lib/ansi.mjs +224 -0
- package/lib/cawdev.mjs +104 -0
- package/lib/code-map.mjs +164 -0
- package/lib/harness-prompt.mjs +197 -0
- package/lib/roadmap-format.mjs +453 -0
- package/lib/run-plugin.mjs +119 -0
- package/lib/secrets.mjs +290 -0
- package/lib/stage-tools.mjs +384 -0
- package/lib/tool-line.mjs +92 -0
- package/lib/tool-rules.mjs +282 -0
- package/lib/transcript-batch.mjs +88 -0
- package/lib/usage-limit.mjs +80 -0
- package/lib/usage-report.mjs +142 -0
- package/lib/usage.mjs +119 -0
- package/mcp/README.md +273 -0
- package/mcp/orchestration-smoke.mjs +267 -0
- package/mcp/server.mjs +2163 -0
- package/mcp/smoke.mjs +220 -0
- package/package.json +20 -0
- package/runner/README.md +930 -0
- package/runner/attach.mjs +2397 -0
- package/runner/banner.mjs +106 -0
- package/runner/bootstrap.mjs +501 -0
- package/runner/brand.mjs +57 -0
- package/runner/cawdev.mjs +414 -0
- package/runner/control.mjs +225 -0
- package/runner/history.mjs +91 -0
- package/runner/input.mjs +355 -0
- package/runner/macbook-laptop.json +48 -0
- package/runner/runner.mjs +7445 -0
- package/runner/scrollback.mjs +165 -0
- package/runner/select.mjs +316 -0
- package/runner/session-store.mjs +78 -0
- package/runner/sign-in.mjs +210 -0
- package/runner/stub-agent.mjs +212 -0
- package/runner/token-store.mjs +107 -0
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
|
+
});
|